chore: apply prettier on the entire project

This commit is contained in:
jubnl
2026-05-25 21:59:42 +02:00
parent c130ed41be
commit 6bcdfbc34b
488 changed files with 82986 additions and 45830 deletions
+41
View File
@@ -0,0 +1,41 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import gitignore from 'eslint-config-flat-gitignore';
export default tseslint.config(
gitignore({ strict: false }),
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
);
+5 -4
View File
@@ -8,9 +8,9 @@
"build": "node scripts/build.mjs",
"start:prod": "node --require tsconfig-paths/register dist/index.js",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,tests}/**/*.ts\" --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run tests/unit",
@@ -96,6 +96,7 @@
"supertest": "^7.2.2",
"tz-lookup": "^6.1.25",
"unplugin-swc": "^1.5.9",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"typescript-eslint": "^8.58.2"
}
}
+1 -1
View File
@@ -9,4 +9,4 @@ export const ADDON_IDS = {
JOURNEY: 'journey',
} as const;
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
export type AddonId = (typeof ADDON_IDS)[keyof typeof ADDON_IDS];
+199 -153
View File
@@ -1,60 +1,59 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import path from 'node:path';
import fs from 'node:fs';
import multer from 'multer';
import { logDebug, logWarn, logError } from './services/auditLog';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
import { ADDON_IDS } from './addons';
import { db } from './db/database';
import authRoutes from './routes/auth';
import tripsRoutes from './routes/trips';
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
import placesRoutes from './routes/places';
import assignmentsRoutes from './routes/assignments';
import packingRoutes from './routes/packing';
import todoRoutes from './routes/todo';
import tagsRoutes from './routes/tags';
import categoriesRoutes from './routes/categories';
import adminRoutes from './routes/admin';
import mapsRoutes from './routes/maps';
import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes';
import settingsRoutes from './routes/settings';
import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab';
import backupRoutes from './routes/backup';
import oidcRoutes from './routes/oidc';
import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
import vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified';
import photoRoutes from './routes/photos';
import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey';
import journeyPublicRoutes from './routes/journeyPublic';
import publicConfigRoutes from './routes/publicConfig';
import systemNoticesRoutes from './routes/systemNotices';
import { mcpHandler } from './mcp';
import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider';
import { Addon } from './types';
import { getPhotoProviderConfig } from './services/memories/helpersService';
import { ALL_SCOPES } from './mcp/scopes';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import adminRoutes from './routes/admin';
import airportsRoutes from './routes/airports';
import assignmentsRoutes from './routes/assignments';
import atlasRoutes from './routes/atlas';
import authRoutes from './routes/auth';
import backupRoutes from './routes/backup';
import budgetRoutes from './routes/budget';
import categoriesRoutes from './routes/categories';
import collabRoutes from './routes/collab';
import dayNotesRoutes from './routes/dayNotes';
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
import filesRoutes from './routes/files';
import journeyRoutes from './routes/journey';
import journeyPublicRoutes from './routes/journeyPublic';
import mapsRoutes from './routes/maps';
import memoriesRoutes from './routes/memories/unified';
import notificationRoutes from './routes/notifications';
import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
import oidcRoutes from './routes/oidc';
import packingRoutes from './routes/packing';
import photoRoutes from './routes/photos';
import placesRoutes from './routes/places';
import publicConfigRoutes from './routes/publicConfig';
import reservationsRoutes from './routes/reservations';
import settingsRoutes from './routes/settings';
import shareRoutes from './routes/share';
import systemNoticesRoutes from './routes/systemNotices';
import tagsRoutes from './routes/tags';
import todoRoutes from './routes/todo';
import tripsRoutes from './routes/trips';
import vacayRoutes from './routes/vacay';
import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService';
import { ADDON_IDS } from './addons';
import { ALL_SCOPES } from './mcp/scopes';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import { logDebug, logWarn, logError } from './services/auditLog';
import { getPhotoProviderConfig } from './services/memories/helpersService';
import { getMcpSafeUrl } from './services/notifications';
import { Addon } from './types';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
import { getMcpSafeUrl } from './services/notifications';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import express, { Request, Response, NextFunction } from 'express';
import helmet from 'helmet';
import multer from 'multer';
import fs from 'node:fs';
import path from 'node:path';
export function createApp(): express.Application {
const app = express();
@@ -65,8 +64,10 @@ export function createApp(): express.Application {
}
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
: null;
? process.env.ALLOWED_ORIGINS.split(',')
.map((o) => o.trim())
.filter(Boolean)
: null;
let corsOrigin: cors.CorsOptions['origin'];
if (allowedOrigins) {
@@ -102,54 +103,65 @@ export function createApp(): express.Application {
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
app.use(
(req: Request, _res: Response, next: NextFunction) => {
if (
req.path.startsWith('/.well-known/') ||
req.path === '/oauth/register' ||
req.path === '/oauth/authorize' ||
req.path === '/oauth/userinfo' ||
req.path === '/mcp'
) {
cors({ origin: '*', credentials: false })(req, _res, next);
} else {
next();
}
},
);
app.use((req: Request, _res: Response, next: NextFunction) => {
if (
req.path.startsWith('/.well-known/') ||
req.path === '/oauth/register' ||
req.path === '/oauth/authorize' ||
req.path === '/oauth/userinfo' ||
req.path === '/mcp'
) {
cors({ origin: '*', credentials: false })(req, _res, next);
} else {
next();
}
});
app.use(cors({ origin: corsOrigin, credentials: true }));
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https:"],
connectSrc: [
"'self'", "ws:", "wss:",
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
"https://places.googleapis.com", "https://api.openweathermap.org",
"https://en.wikipedia.org", "https://commons.wikimedia.org",
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
"https://router.project-osrm.org/route/v1/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
],
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
frameAncestors: ["'self'"],
upgradeInsecureRequests: shouldForceHttps ? [] : null
}
},
crossOriginEmbedderPolicy: false,
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://unpkg.com'],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: [
"'self'",
'ws:',
'wss:',
'https://nominatim.openstreetmap.org',
'https://overpass-api.de',
'https://places.googleapis.com',
'https://api.openweathermap.org',
'https://en.wikipedia.org',
'https://commons.wikimedia.org',
'https://*.basemaps.cartocdn.com',
'https://*.tile.openstreetmap.org',
'https://unpkg.com',
'https://open-meteo.com',
'https://api.open-meteo.com',
'https://geocoding-api.open-meteo.com',
'https://api.exchangerate-api.com',
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson',
'https://router.project-osrm.org/route/v1/',
'https://api.mapbox.com',
'https://*.tiles.mapbox.com',
'https://events.mapbox.com',
],
workerSrc: ["'self'", 'blob:'],
childSrc: ["'self'", 'blob:'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
frameAncestors: ["'self'"],
upgradeInsecureRequests: shouldForceHttps ? [] : null,
},
},
crossOriginEmbedderPolicy: false,
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}),
);
if (shouldForceHttps) {
app.use((req: Request, res: Response, next: NextFunction) => {
@@ -166,7 +178,19 @@ export function createApp(): express.Application {
// Request logging with sensitive field redaction
{
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']);
const SENSITIVE_KEYS = new Set([
'password',
'new_password',
'current_password',
'token',
'jwt',
'authorization',
'cookie',
'client_secret',
'mfa_token',
'code',
'smtp_pass',
]);
const redact = (value: unknown): unknown => {
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return (value as unknown[]).map(redact);
@@ -245,12 +269,16 @@ export function createApp(): express.Application {
// Share-token path: require the token to cover the exact trip the
// photo belongs to. Expired tokens fall through to 401.
const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as { trip_id: number } | undefined;
const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as
| { trip_id: number }
| undefined;
if (!photo) return res.status(401).send('Authentication required');
const share = db.prepare(
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(rawToken) as { trip_id: number } | undefined;
const share = db
.prepare(
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
)
.get(rawToken) as { trip_id: number } | undefined;
if (!share || share.trip_id !== photo.trip_id) {
return res.status(401).send('Authentication required');
}
@@ -277,8 +305,8 @@ export function createApp(): express.Application {
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.get('/api/health', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-store, must-revalidate')
res.json({ status: 'ok' })
res.setHeader('Cache-Control', 'no-store, must-revalidate');
res.json({ status: 'ok' });
});
app.use('/api/config', publicConfigRoutes);
app.use('/api', assignmentsRoutes);
@@ -288,18 +316,28 @@ export function createApp(): express.Application {
// Addons list endpoint
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
const providers = db.prepare(`
const addons = db
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
.all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
const providers = db
.prepare(
`
SELECT id, name, icon, enabled, sort_order
FROM photo_providers
WHERE enabled = 1
ORDER BY sort_order, id
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
const fields = db.prepare(`
`,
)
.all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
const fields = db
.prepare(
`
SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
ORDER BY sort_order, id
`).all() as Array<{
`,
)
.all() as Array<{
provider_id: string;
field_key: string;
label: string;
@@ -323,15 +361,15 @@ export function createApp(): express.Application {
res.json({
collabFeatures: getCollabFeatures(),
addons: [
...addons.map(a => ({ ...a, enabled: !!a.enabled })),
...providers.map(p => ({
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
...providers.map((p) => ({
id: p.id,
name: p.name,
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
@@ -351,10 +389,14 @@ export function createApp(): express.Application {
// Addon routes
app.use('/api/addons/vacay', vacayRoutes);
app.use('/api/addons/atlas', atlasRoutes);
app.use('/api/journeys', (req, res, next) => {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
next();
}, journeyRoutes);
app.use(
'/api/journeys',
(req, res, next) => {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
next();
},
journeyRoutes,
);
app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
@@ -391,16 +433,16 @@ export function createApp(): express.Application {
if (_oauthMetadata) return _oauthMetadata;
const base = getMcpSafeUrl().replace(/\/+$/, '');
_oauthMetadata = {
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
scopes_supported: ALL_SCOPES,
};
return _oauthMetadata;
}
@@ -446,11 +488,11 @@ export function createApp(): express.Application {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const meta = getOAuthMetadata();
res.json({
resource: `${meta.issuer}/mcp`,
authorization_servers: [meta.issuer],
resource: `${meta.issuer}/mcp`,
authorization_servers: [meta.issuer],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
@@ -488,13 +530,15 @@ export function createApp(): express.Application {
// Production static file serving
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
}));
app.use(
express.static(publicPath, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
}),
);
app.get('*', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(publicPath, 'index.html'));
@@ -502,21 +546,23 @@ export function createApp(): express.Application {
}
// Global error handler
app.use((err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => {
if (process.env.NODE_ENV === 'production') {
console.error('Unhandled error:', err.message);
} else {
console.error('Unhandled error:', err);
}
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message });
}
const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
});
app.use(
(err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => {
if (process.env.NODE_ENV === 'production') {
console.error('Unhandled error:', err.message);
} else {
console.error('Unhandled error:', err);
}
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message });
}
const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
},
);
return app;
}
}
+11 -3
View File
@@ -19,7 +19,10 @@ try {
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
console.log('Generated and saved JWT secret to', jwtSecretFile);
} catch (writeErr: unknown) {
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
console.warn(
'WARNING: Could not persist JWT secret to disk:',
writeErr instanceof Error ? writeErr.message : writeErr,
);
console.warn('Sessions will reset on server restart.');
}
}
@@ -92,7 +95,10 @@ if (_encryptionKey) {
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
console.log('Encryption key persisted to', encKeyFile);
} catch (writeErr: unknown) {
console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
console.warn(
'WARNING: Could not persist encryption key to disk:',
writeErr instanceof Error ? writeErr.message : writeErr,
);
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
}
}
@@ -107,6 +113,8 @@ export const ENCRYPTION_KEY = _encryptionKey;
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar'];
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
console.warn(
`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`,
);
}
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
+45 -22
View File
@@ -1,10 +1,11 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { createTables } from './schema';
import { runMigrations } from './migrations';
import { runSeeds } from './seeds';
import { Place, Tag } from '../types';
import { runMigrations } from './migrations';
import { createTables } from './schema';
import { runSeeds } from './seeds';
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
// In test mode each vitest worker gets an isolated in-memory DB so that
// parallel forks can't race on the same file or share migration state.
@@ -25,8 +26,12 @@ let _db: Database.Database | null = null;
function initDb(): void {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
try {
_db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
} catch (e) {}
try {
_db.close();
} catch (e) {}
_db = null;
}
@@ -66,8 +71,12 @@ if (process.env.DEMO_MODE?.toLowerCase() === 'true') {
function closeDb(): void {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
try {
_db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
} catch (e) {}
try {
_db.close();
} catch (e) {}
_db = null;
console.log('[DB] Database connection closed');
}
@@ -92,29 +101,39 @@ interface PlaceWithTags extends Place {
}
function getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
const place = db.prepare(`
const place = db
.prepare(
`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.id = ?
`).get(placeId) as PlaceWithCategory | undefined;
`,
)
.get(placeId) as PlaceWithCategory | undefined;
if (!place) return null;
const tags = db.prepare(`
const tags = db
.prepare(
`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(placeId) as Tag[];
`,
)
.all(placeId) as Tag[];
return {
...place,
category: place.category_id ? {
id: place.category_id,
name: place.category_name!,
color: place.category_color!,
icon: place.category_icon!,
} : null,
category: place.category_id
? {
id: place.category_id,
name: place.category_name,
color: place.category_color,
icon: place.category_icon,
}
: null,
tags,
};
}
@@ -125,11 +144,15 @@ interface TripAccess {
}
function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined {
return db.prepare(`
return db
.prepare(
`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId) as TripAccess | undefined;
`,
)
.get(userId, tripId, userId) as TripAccess | undefined;
}
function isOwner(tripId: number | string, userId: number): boolean {
File diff suppressed because it is too large Load Diff
+199 -22
View File
@@ -43,7 +43,9 @@ function seedAdminAccount(db: Database.Database): void {
const hash = bcrypt.hashSync(password, 12);
const username = 'admin';
db.prepare('INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)').run(username, email, hash, 'admin');
db.prepare(
'INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)',
).run(username, email, hash, 'admin');
console.log('');
console.log('╔══════════════════════════════════════════════╗');
@@ -86,18 +88,93 @@ function seedCategories(db: Database.Database): void {
function seedAddons(db: Database.Database): void {
try {
const defaultAddons = [
{ id: 'packing', name: 'Lists', description: 'Packing lists and to-do tasks for your trips', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
{
id: 'packing',
name: 'Lists',
description: 'Packing lists and to-do tasks for your trips',
type: 'trip',
icon: 'ListChecks',
enabled: 1,
sort_order: 0,
},
{
id: 'budget',
name: 'Budget Planner',
description: 'Track expenses and plan your travel budget',
type: 'trip',
icon: 'Wallet',
enabled: 1,
sort_order: 1,
},
{
id: 'documents',
name: 'Documents',
description: 'Store and manage travel documents',
type: 'trip',
icon: 'FileText',
enabled: 1,
sort_order: 2,
},
{
id: 'vacay',
name: 'Vacay',
description: 'Personal vacation day planner with calendar view',
type: 'global',
icon: 'CalendarDays',
enabled: 1,
sort_order: 10,
},
{
id: 'atlas',
name: 'Atlas',
description: 'World map of your visited countries with travel stats',
type: 'global',
icon: 'Globe',
enabled: 1,
sort_order: 11,
},
{
id: 'mcp',
name: 'MCP',
description: 'Model Context Protocol for AI assistant integration',
type: 'integration',
icon: 'Terminal',
enabled: 0,
sort_order: 12,
},
{
id: 'naver_list_import',
name: 'Naver List Import',
description: 'Import places from shared Naver Maps lists',
type: 'trip',
icon: 'Link2',
enabled: 1,
sort_order: 13,
},
{
id: 'collab',
name: 'Collab',
description: 'Notes, polls, and live chat for trip collaboration',
type: 'trip',
icon: 'Users',
enabled: 1,
sort_order: 6,
},
{
id: 'journey',
name: 'Journey',
description: 'Trip tracking & travel journal — check-ins, photos, daily stories',
type: 'global',
icon: 'Compass',
enabled: 0,
sort_order: 35,
},
];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
const insertAddon = db.prepare(
'INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
for (const a of defaultAddons)
insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
const providerRows = [
{
@@ -117,21 +194,121 @@ function seedAddons(db: Database.Database): void {
sort_order: 1,
},
];
const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
const insertProvider = db.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
);
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order);
const providerFields = [
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', hint: null, required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com/photo', hint: 'providerUrlHintSynology', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', hint: null, required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
{ provider_id: 'synologyphotos', field_key: 'synology_otp', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: null, required: 0, secret: 0, settings_key: null, payload_key: 'synology_otp', sort_order: 3 },
{ provider_id: 'synologyphotos', field_key: 'synology_skip_ssl', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'synology_skip_ssl', payload_key: 'synology_skip_ssl', sort_order: 4 },
{
provider_id: 'immich',
field_key: 'immich_url',
label: 'providerUrl',
input_type: 'url',
placeholder: 'https://immich.example.com',
hint: null,
required: 1,
secret: 0,
settings_key: 'immich_url',
payload_key: 'immich_url',
sort_order: 0,
},
{
provider_id: 'immich',
field_key: 'immich_api_key',
label: 'providerApiKey',
input_type: 'password',
placeholder: 'API Key',
hint: null,
required: 1,
secret: 1,
settings_key: null,
payload_key: 'immich_api_key',
sort_order: 1,
},
{
provider_id: 'synologyphotos',
field_key: 'synology_url',
label: 'providerUrl',
input_type: 'url',
placeholder: 'https://synology.example.com/photo',
hint: 'providerUrlHintSynology',
required: 1,
secret: 0,
settings_key: 'synology_url',
payload_key: 'synology_url',
sort_order: 0,
},
{
provider_id: 'synologyphotos',
field_key: 'synology_username',
label: 'providerUsername',
input_type: 'text',
placeholder: 'Username',
hint: null,
required: 1,
secret: 0,
settings_key: 'synology_username',
payload_key: 'synology_username',
sort_order: 1,
},
{
provider_id: 'synologyphotos',
field_key: 'synology_password',
label: 'providerPassword',
input_type: 'password',
placeholder: 'Password',
hint: null,
required: 1,
secret: 1,
settings_key: null,
payload_key: 'synology_password',
sort_order: 2,
},
{
provider_id: 'synologyphotos',
field_key: 'synology_otp',
label: 'providerOTP',
input_type: 'text',
placeholder: '123456',
hint: null,
required: 0,
secret: 0,
settings_key: null,
payload_key: 'synology_otp',
sort_order: 3,
},
{
provider_id: 'synologyphotos',
field_key: 'synology_skip_ssl',
label: 'skipSSLVerification',
input_type: 'checkbox',
placeholder: null,
hint: null,
required: 0,
secret: 0,
settings_key: 'synology_skip_ssl',
payload_key: 'synology_skip_ssl',
sort_order: 4,
},
];
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
const insertProviderField = db.prepare(
'INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
for (const f of providerFields) {
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
insertProviderField.run(
f.provider_id,
f.field_key,
f.label,
f.input_type,
f.placeholder,
f.hint,
f.required,
f.secret,
f.settings_key,
f.payload_key,
f.sort_order,
);
}
console.log('Default addons seeded');
} catch (err: unknown) {
+36 -18
View File
@@ -15,18 +15,28 @@ function resetDemoUser(): void {
// Save admin's current credentials and API keys (these should survive the reset)
const adminEmail = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app';
interface AdminData { password_hash: string; maps_api_key: string | null; openweather_api_key: string | null; unsplash_api_key: string | null; avatar: string | null; }
interface AdminData {
password_hash: string;
maps_api_key: string | null;
openweather_api_key: string | null;
unsplash_api_key: string | null;
avatar: string | null;
}
let adminData: AdminData | undefined = undefined;
try {
adminData = db.prepare(
'SELECT password_hash, maps_api_key, openweather_api_key, unsplash_api_key, avatar FROM users WHERE email = ?'
).get(adminEmail) as AdminData | undefined;
adminData = db
.prepare(
'SELECT password_hash, maps_api_key, openweather_api_key, unsplash_api_key, avatar FROM users WHERE email = ?',
)
.get(adminEmail) as AdminData | undefined;
} catch (e: unknown) {
console.error('[Demo Reset] Failed to read admin data:', e instanceof Error ? e.message : e);
}
// Flush WAL to main DB file
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try {
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
} catch (e) {}
// Close DB connection
closeDb();
@@ -35,8 +45,12 @@ function resetDemoUser(): void {
try {
fs.copyFileSync(baselinePath, dbPath);
// Remove WAL/SHM files if they exist (stale from old connection)
try { fs.unlinkSync(dbPath + '-wal'); } catch (e) {}
try { fs.unlinkSync(dbPath + '-shm'); } catch (e) {}
try {
fs.unlinkSync(dbPath + '-wal');
} catch (e) {}
try {
fs.unlinkSync(dbPath + '-shm');
} catch (e) {}
} catch (e: unknown) {
console.error('[Demo Reset] Failed to restore baseline:', e instanceof Error ? e.message : e);
reinitialize();
@@ -50,16 +64,18 @@ function resetDemoUser(): void {
if (adminData) {
try {
const { db: freshDb } = require('../db/database');
freshDb.prepare(
'UPDATE users SET password_hash = ?, maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE email = ?'
).run(
adminData.password_hash,
adminData.maps_api_key,
adminData.openweather_api_key,
adminData.unsplash_api_key,
adminData.avatar,
adminEmail
);
freshDb
.prepare(
'UPDATE users SET password_hash = ?, maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE email = ?',
)
.run(
adminData.password_hash,
adminData.maps_api_key,
adminData.openweather_api_key,
adminData.unsplash_api_key,
adminData.avatar,
adminEmail,
);
} catch (e: unknown) {
console.error('[Demo Reset] Failed to restore admin credentials:', e instanceof Error ? e.message : e);
}
@@ -72,7 +88,9 @@ function saveBaseline(): void {
const { db } = require('../db/database');
// Flush WAL so baseline file is self-contained
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try {
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
} catch (e) {}
fs.copyFileSync(dbPath, baselinePath);
console.log('[Demo] Baseline saved');
+659 -67
View File
@@ -12,7 +12,9 @@ function seedDemoData(db: Database.Database): { adminId: number; demoId: number
let admin = db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL) as { id: number } | undefined;
if (!admin) {
const hash = bcrypt.hashSync(ADMIN_PASS, 10);
const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(ADMIN_USER, ADMIN_EMAIL, hash, 'admin');
const r = db
.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
.run(ADMIN_USER, ADMIN_EMAIL, hash, 'admin');
admin = { id: Number(r.lastInsertRowid) };
console.log('[Demo] Admin user created');
} else {
@@ -23,7 +25,9 @@ function seedDemoData(db: Database.Database): { adminId: number; demoId: number
let demo = db.prepare('SELECT id FROM users WHERE email = ?').get(DEMO_EMAIL) as { id: number } | undefined;
if (!demo) {
const hash = bcrypt.hashSync(DEMO_PASS, 10);
const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run('demo', DEMO_EMAIL, hash, 'user');
const r = db
.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
.run('demo', DEMO_EMAIL, hash, 'user');
demo = { id: Number(r.lastInsertRowid) };
console.log('[Demo] Demo user created');
} else {
@@ -34,7 +38,9 @@ function seedDemoData(db: Database.Database): { adminId: number; demoId: number
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
// Check if admin already has example trips
const adminTrips = db.prepare('SELECT COUNT(*) as count FROM trips WHERE user_id = ?').get(admin.id) as { count: number };
const adminTrips = db.prepare('SELECT COUNT(*) as count FROM trips WHERE user_id = ?').get(admin.id) as {
count: number;
};
if (adminTrips.count > 0) {
console.log('[Demo] Example trips already exist, ensuring demo membership');
ensureDemoMembership(db, admin.id, demo.id);
@@ -62,20 +68,39 @@ function ensureDemoMembership(db: Database.Database, adminId: number, demoId: nu
}
function seedExampleTrips(db: Database.Database, adminId: number, demoId: number): void {
const insertTrip = db.prepare('INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)');
const insertTrip = db.prepare(
'INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)',
);
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
const insertPlace = db.prepare(
'INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
const insertAssignment = db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)');
const insertPacking = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)');
const insertBudget = db.prepare('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)');
const insertReservation = db.prepare('INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
const insertPacking = db.prepare(
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)',
);
const insertBudget = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)',
);
const insertReservation = db.prepare(
'INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
);
const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)');
const insertNote = db.prepare('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
const insertNote = db.prepare(
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
);
// Category IDs: 1=Hotel, 2=Restaurant, 3=Attraction, 5=Transport, 7=Bar/Cafe, 8=Beach, 9=Nature, 6=Entertainment
// --- Trip 1: Tokyo & Kyoto ---
const trip1 = insertTrip.run(adminId, 'Tokyo & Kyoto', 'Two weeks in Japan — from the neon-lit streets of Tokyo to the serene temples of Kyoto.', '2026-04-15', '2026-04-21', 'JPY');
const trip1 = insertTrip.run(
adminId,
'Tokyo & Kyoto',
'Two weeks in Japan — from the neon-lit streets of Tokyo to the serene temples of Kyoto.',
'2026-04-15',
'2026-04-21',
'JPY',
);
const t1 = Number(trip1.lastInsertRowid);
const t1days: number[] = [];
@@ -84,23 +109,219 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
t1days.push(Number(d.lastInsertRowid));
}
const t1places: [number, string, number, number, string, number, string, number, string, string | null, string | null, string | null, string | null][] = [
[t1, 'Hotel Shinjuku Granbell', 35.6938, 139.7035, '2-14-5 Kabukicho, Shinjuku City, Tokyo 160-0021, Japan', 1, '15:00', 60, 'Check-in from 3 PM. Steps from Shinjuku Station.', null, 'ChIJdaGEJBeMGGARYgt8sLBv6lM', 'https://www.grfranbellhotel.jp/shinjuku/', '+81 3-5155-2666'],
[t1, 'Senso-ji Temple', 35.7148, 139.7967, '2 Chome-3-1 Asakusa, Taito City, Tokyo 111-0032, Japan', 3, '09:00', 90, 'Oldest temple in Tokyo. Fewer tourists in the early morning.', null, 'ChIJ8T1GpMGOGGARDYGSgpoOdfg', 'https://www.senso-ji.jp/', '+81 3-3842-0181'],
[t1, 'Shibuya Crossing', 35.6595, 139.7004, '2 Chome-2-1 Dogenzaka, Shibuya City, Tokyo 150-0043, Japan', 3, '18:00', 45, 'World\'s busiest pedestrian crossing. Most impressive at night.', null, 'ChIJLyzOhmyLGGARMKWbl5z6wGg', null, null],
[t1, 'Tsukiji Outer Market', 35.6654, 139.7707, '4 Chome-16-2 Tsukiji, Chuo City, Tokyo 104-0045, Japan', 2, '08:00', 120, 'Fresh sushi for breakfast! Explore the street food stalls.', null, 'ChIJq2i1dZCLGGAR1TfoBRo25VU', 'https://www.tsukiji.or.jp/', null],
[t1, 'Meiji Jingu Shrine', 35.6764, 139.6993, '1-1 Yoyogikamizonocho, Shibuya City, Tokyo 151-8557, Japan', 3, '10:00', 75, 'Peaceful oasis in the middle of the city. Walk through the forest to the shrine.', null, 'ChIJ5SuJSByMGGARMg9qOlTFgkc', 'https://www.meijijingu.or.jp/', '+81 3-3379-5511'],
[t1, 'Akihabara Electric Town', 35.7023, 139.7745, 'Sotokanda, Chiyoda City, Tokyo, Japan', 3, '14:00', 180, 'Electric Town — anime, manga, electronics. Retro gaming shops!', null, 'ChIJGz1usEyMGGAR1mYByqOOJao', null, null],
[t1, 'Shinkansen to Kyoto', 35.6812, 139.7671, '1 Chome Marunouchi, Chiyoda City, Tokyo 100-0005, Japan', 5, '08:30', 140, 'Nozomi Shinkansen, approx. 2h15. Window seat for Mt. Fuji views!', null, 'ChIJC3Cf2PuLGGAROO00ukl8JwA', null, null],
[t1, 'Hotel Granvia Kyoto', 34.9856, 135.7580, 'Karasuma-dori Shiokoji-sagaru, Shimogyo-ku, Kyoto 600-8216, Japan', 1, '14:00', 60, 'Right at Kyoto Station. Perfect base for day trips.', null, 'ChIJUf6MDFcIAWARLihjKC9FWDY', 'https://www.granvia-kyoto.co.jp/', '+81 75-344-8888'],
[t1, 'Fushimi Inari Taisha', 34.9671, 135.7727, '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto 612-0882, Japan', 3, '07:00', 150, '10,000 vermillion torii gates. Start early for empty paths!', null, 'ChIJIW0JRbMIAWARPYEzP5LVHGE', 'http://inari.jp/', '+81 75-641-7331'],
[t1, 'Kinkaku-ji (Golden Pavilion)', 35.0394, 135.7292, '1 Kinkakujicho, Kita Ward, Kyoto 603-8361, Japan', 3, '10:00', 60, 'The golden temple reflected in the mirror pond. Iconic photo spot.', null, 'ChIJvUbrwCCoAWAR5-uyAXPzBHg', null, '+81 75-461-0013'],
[t1, 'Arashiyama Bamboo Grove', 35.0095, 135.6673, 'Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto 616-8385, Japan', 9, '09:00', 90, 'Magical bamboo forest. Best visited in the morning before the crowds.', null, 'ChIJFS4EvA6pAWARQsAPVijvW7I', null, null],
[t1, 'Nishiki Market', 35.0050, 135.7647, 'Nishiki-koji Dori, Nakagyo Ward, Kyoto 604-8054, Japan', 2, '12:00', 90, 'Kyoto\'s kitchen street. Try the matcha ice cream and fresh mochi!', null, 'ChIJ09zzUigJAWARXzIdh1NE3hQ', 'http://www.kyoto-nishiki.or.jp/', null],
[t1, 'Gion District', 35.0037, 135.7755, 'Gionmachi Minamigawa, Higashiyama Ward, Kyoto 605-0074, Japan', 3, '17:00', 120, 'Historic geisha district. Best chance of spotting a maiko in the evening.', null, 'ChIJ7WWWjfYJAWARGqEHAfXIzgQ', null, null],
const t1places: [
number,
string,
number,
number,
string,
number,
string,
number,
string,
string | null,
string | null,
string | null,
string | null,
][] = [
[
t1,
'Hotel Shinjuku Granbell',
35.6938,
139.7035,
'2-14-5 Kabukicho, Shinjuku City, Tokyo 160-0021, Japan',
1,
'15:00',
60,
'Check-in from 3 PM. Steps from Shinjuku Station.',
null,
'ChIJdaGEJBeMGGARYgt8sLBv6lM',
'https://www.grfranbellhotel.jp/shinjuku/',
'+81 3-5155-2666',
],
[
t1,
'Senso-ji Temple',
35.7148,
139.7967,
'2 Chome-3-1 Asakusa, Taito City, Tokyo 111-0032, Japan',
3,
'09:00',
90,
'Oldest temple in Tokyo. Fewer tourists in the early morning.',
null,
'ChIJ8T1GpMGOGGARDYGSgpoOdfg',
'https://www.senso-ji.jp/',
'+81 3-3842-0181',
],
[
t1,
'Shibuya Crossing',
35.6595,
139.7004,
'2 Chome-2-1 Dogenzaka, Shibuya City, Tokyo 150-0043, Japan',
3,
'18:00',
45,
"World's busiest pedestrian crossing. Most impressive at night.",
null,
'ChIJLyzOhmyLGGARMKWbl5z6wGg',
null,
null,
],
[
t1,
'Tsukiji Outer Market',
35.6654,
139.7707,
'4 Chome-16-2 Tsukiji, Chuo City, Tokyo 104-0045, Japan',
2,
'08:00',
120,
'Fresh sushi for breakfast! Explore the street food stalls.',
null,
'ChIJq2i1dZCLGGAR1TfoBRo25VU',
'https://www.tsukiji.or.jp/',
null,
],
[
t1,
'Meiji Jingu Shrine',
35.6764,
139.6993,
'1-1 Yoyogikamizonocho, Shibuya City, Tokyo 151-8557, Japan',
3,
'10:00',
75,
'Peaceful oasis in the middle of the city. Walk through the forest to the shrine.',
null,
'ChIJ5SuJSByMGGARMg9qOlTFgkc',
'https://www.meijijingu.or.jp/',
'+81 3-3379-5511',
],
[
t1,
'Akihabara Electric Town',
35.7023,
139.7745,
'Sotokanda, Chiyoda City, Tokyo, Japan',
3,
'14:00',
180,
'Electric Town — anime, manga, electronics. Retro gaming shops!',
null,
'ChIJGz1usEyMGGAR1mYByqOOJao',
null,
null,
],
[
t1,
'Shinkansen to Kyoto',
35.6812,
139.7671,
'1 Chome Marunouchi, Chiyoda City, Tokyo 100-0005, Japan',
5,
'08:30',
140,
'Nozomi Shinkansen, approx. 2h15. Window seat for Mt. Fuji views!',
null,
'ChIJC3Cf2PuLGGAROO00ukl8JwA',
null,
null,
],
[
t1,
'Hotel Granvia Kyoto',
34.9856,
135.758,
'Karasuma-dori Shiokoji-sagaru, Shimogyo-ku, Kyoto 600-8216, Japan',
1,
'14:00',
60,
'Right at Kyoto Station. Perfect base for day trips.',
null,
'ChIJUf6MDFcIAWARLihjKC9FWDY',
'https://www.granvia-kyoto.co.jp/',
'+81 75-344-8888',
],
[
t1,
'Fushimi Inari Taisha',
34.9671,
135.7727,
'68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto 612-0882, Japan',
3,
'07:00',
150,
'10,000 vermillion torii gates. Start early for empty paths!',
null,
'ChIJIW0JRbMIAWARPYEzP5LVHGE',
'http://inari.jp/',
'+81 75-641-7331',
],
[
t1,
'Kinkaku-ji (Golden Pavilion)',
35.0394,
135.7292,
'1 Kinkakujicho, Kita Ward, Kyoto 603-8361, Japan',
3,
'10:00',
60,
'The golden temple reflected in the mirror pond. Iconic photo spot.',
null,
'ChIJvUbrwCCoAWAR5-uyAXPzBHg',
null,
'+81 75-461-0013',
],
[
t1,
'Arashiyama Bamboo Grove',
35.0095,
135.6673,
'Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto 616-8385, Japan',
9,
'09:00',
90,
'Magical bamboo forest. Best visited in the morning before the crowds.',
null,
'ChIJFS4EvA6pAWARQsAPVijvW7I',
null,
null,
],
[
t1,
'Nishiki Market',
35.005,
135.7647,
'Nishiki-koji Dori, Nakagyo Ward, Kyoto 604-8054, Japan',
2,
'12:00',
90,
"Kyoto's kitchen street. Try the matcha ice cream and fresh mochi!",
null,
'ChIJ09zzUigJAWARXzIdh1NE3hQ',
'http://www.kyoto-nishiki.or.jp/',
null,
],
[
t1,
'Gion District',
35.0037,
135.7755,
'Gionmachi Minamigawa, Higashiyama Ward, Kyoto 605-0074, Japan',
3,
'17:00',
120,
'Historic geisha district. Best chance of spotting a maiko in the evening.',
null,
'ChIJ7WWWjfYJAWARGqEHAfXIzgQ',
null,
null,
],
];
const t1pIds = t1places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
const t1pIds = t1places.map((p) => Number(insertPlace.run(...p).lastInsertRowid));
// Day 1: Hotel Check-in, Shibuya
insertAssignment.run(t1days[0], t1pIds[0], 0);
@@ -129,13 +350,18 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
// Packing
const t1packing: [string, number, string, number][] = [
['Passport', 1, 'Documents', 0], ['Japan Rail Pass', 1, 'Documents', 1],
['Power adapter Type A/B', 0, 'Electronics', 2], ['Camera + charger', 0, 'Electronics', 3],
['Comfortable walking shoes', 0, 'Clothing', 4], ['Rain jacket', 0, 'Clothing', 5],
['Sunscreen', 0, 'Toiletries', 6], ['Travel first aid kit', 0, 'Toiletries', 7],
['Pocket WiFi confirmation', 1, 'Electronics', 8], ['Yen cash', 0, 'Documents', 9],
['Passport', 1, 'Documents', 0],
['Japan Rail Pass', 1, 'Documents', 1],
['Power adapter Type A/B', 0, 'Electronics', 2],
['Camera + charger', 0, 'Electronics', 3],
['Comfortable walking shoes', 0, 'Clothing', 4],
['Rain jacket', 0, 'Clothing', 5],
['Sunscreen', 0, 'Toiletries', 6],
['Travel first aid kit', 0, 'Toiletries', 7],
['Pocket WiFi confirmation', 1, 'Electronics', 8],
['Yen cash', 0, 'Documents', 9],
];
t1packing.forEach(p => insertPacking.run(t1, ...p));
t1packing.forEach((p) => insertPacking.run(t1, ...p));
// Budget
insertBudget.run(t1, 'Accommodation', 'Hotel Shinjuku (3 nights)', 67500, 2, 'Double room');
@@ -146,13 +372,38 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
insertBudget.run(t1, 'Activities', 'Temple entries & experiences', 18000, 2, null);
// Reservations
insertReservation.run(t1, t1days[0], 'Hotel Shinjuku Check-in', '15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo');
insertReservation.run(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station');
insertReservation.run(
t1,
t1days[0],
'Hotel Shinjuku Check-in',
'15:00',
'SG-2026-78432',
'confirmed',
'hotel',
'Shinjuku, Tokyo',
);
insertReservation.run(
t1,
t1days[3],
'Shinkansen Tokyo → Kyoto',
'08:30',
'JR-NOZOMI-445',
'confirmed',
'transport',
'Tokyo Station',
);
insertMember.run(t1, demoId, adminId);
// --- Trip 2: Barcelona Long Weekend ---
const trip2 = insertTrip.run(adminId, 'Barcelona Long Weekend', 'Gaudi, tapas, and Mediterranean vibes — a long weekend in the Catalan capital.', '2026-05-21', '2026-05-24', 'EUR');
const trip2 = insertTrip.run(
adminId,
'Barcelona Long Weekend',
'Gaudi, tapas, and Mediterranean vibes — a long weekend in the Catalan capital.',
'2026-05-21',
'2026-05-24',
'EUR',
);
const t2 = Number(trip2.lastInsertRowid);
const t2days: number[] = [];
@@ -161,18 +412,144 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
t2days.push(Number(d.lastInsertRowid));
}
const t2places: [number, string, number, number, string, number, string, number, string, string | null, string | null, string | null, string | null][] = [
[t2, 'W Barcelona', 41.3686, 2.1920, 'Placa de la Rosa dels Vents 1, 08039 Barcelona, Spain', 1, '14:00', 60, 'Right on the beach. Rooftop bar with panoramic views!', null, 'ChIJKfj5C8yjpBIRCPC3RPI0JO4', 'https://www.marriott.com/hotels/travel/bcnwh-w-barcelona/', '+34 932 95 28 00'],
[t2, 'Sagrada Familia', 41.4036, 2.1744, 'C/ de Mallorca, 401, 08013 Barcelona, Spain', 3, '10:00', 120, 'Gaudi\'s masterpiece. Book tickets online in advance — sells out fast!', null, 'ChIJk_s92NyipBIRUMnDG8Kq2Js', 'https://sagradafamilia.org/', '+34 932 08 04 14'],
[t2, 'Park Guell', 41.4145, 2.1527, '08024 Barcelona, Spain', 3, '09:00', 90, 'Mosaic terrace with city views. Book early for the Monumental Zone.', null, 'ChIJ4eQMeOmipBIRb65JRUzGE8k', 'https://parkguell.barcelona/', '+34 934 09 18 31'],
[t2, 'La Boqueria Market', 41.3816, 2.1717, 'La Rambla, 91, 08001 Barcelona, Spain', 2, '12:00', 75, 'Famous market on La Rambla. Fresh juice, jamon iberico, and seafood!', null, 'ChIJB_RfKcuipBIRkPKW7MzVGKg', 'http://www.boqueria.barcelona/', '+34 933 18 25 84'],
[t2, 'Barceloneta Beach', 41.3784, 2.1925, 'Passeig Maritim de la Barceloneta, 08003 Barcelona, Spain', 8, '16:00', 120, 'City beach to unwind after sightseeing. Great chiringuitos nearby.', null, 'ChIJAQCl79-ipBIRUKF3myrMYkM', null, null],
[t2, 'Gothic Quarter', 41.3834, 2.1762, 'Barri Gotic, 08002 Barcelona, Spain', 3, '15:00', 90, 'Medieval lanes, the cathedral, and Placa Reial. Get lost in the alleys!', null, 'ChIJ4_xkvv2ipBIRrK3bdd-lHgo', null, null],
[t2, 'Casa Batllo', 41.3916, 2.1650, 'Passeig de Gracia, 43, 08007 Barcelona, Spain', 3, '11:00', 75, 'Gaudi\'s dragon house. The facade alone is worth the visit.', null, 'ChIJ-2VKIcaipBIRKK63H5PYjqQ', 'https://www.casabatllo.es/', '+34 932 16 03 06'],
[t2, 'El Born & Tapas', 41.3856, 2.1825, 'El Born, 08003 Barcelona, Spain', 7, '20:00', 120, 'Trendy neighborhood with the best tapas bars. Try Cal Pep or El Xampanyet!', null, 'ChIJNY56dxuipBIRbqjSczmLvIA', null, null],
const t2places: [
number,
string,
number,
number,
string,
number,
string,
number,
string,
string | null,
string | null,
string | null,
string | null,
][] = [
[
t2,
'W Barcelona',
41.3686,
2.192,
'Placa de la Rosa dels Vents 1, 08039 Barcelona, Spain',
1,
'14:00',
60,
'Right on the beach. Rooftop bar with panoramic views!',
null,
'ChIJKfj5C8yjpBIRCPC3RPI0JO4',
'https://www.marriott.com/hotels/travel/bcnwh-w-barcelona/',
'+34 932 95 28 00',
],
[
t2,
'Sagrada Familia',
41.4036,
2.1744,
'C/ de Mallorca, 401, 08013 Barcelona, Spain',
3,
'10:00',
120,
"Gaudi's masterpiece. Book tickets online in advance — sells out fast!",
null,
'ChIJk_s92NyipBIRUMnDG8Kq2Js',
'https://sagradafamilia.org/',
'+34 932 08 04 14',
],
[
t2,
'Park Guell',
41.4145,
2.1527,
'08024 Barcelona, Spain',
3,
'09:00',
90,
'Mosaic terrace with city views. Book early for the Monumental Zone.',
null,
'ChIJ4eQMeOmipBIRb65JRUzGE8k',
'https://parkguell.barcelona/',
'+34 934 09 18 31',
],
[
t2,
'La Boqueria Market',
41.3816,
2.1717,
'La Rambla, 91, 08001 Barcelona, Spain',
2,
'12:00',
75,
'Famous market on La Rambla. Fresh juice, jamon iberico, and seafood!',
null,
'ChIJB_RfKcuipBIRkPKW7MzVGKg',
'http://www.boqueria.barcelona/',
'+34 933 18 25 84',
],
[
t2,
'Barceloneta Beach',
41.3784,
2.1925,
'Passeig Maritim de la Barceloneta, 08003 Barcelona, Spain',
8,
'16:00',
120,
'City beach to unwind after sightseeing. Great chiringuitos nearby.',
null,
'ChIJAQCl79-ipBIRUKF3myrMYkM',
null,
null,
],
[
t2,
'Gothic Quarter',
41.3834,
2.1762,
'Barri Gotic, 08002 Barcelona, Spain',
3,
'15:00',
90,
'Medieval lanes, the cathedral, and Placa Reial. Get lost in the alleys!',
null,
'ChIJ4_xkvv2ipBIRrK3bdd-lHgo',
null,
null,
],
[
t2,
'Casa Batllo',
41.3916,
2.165,
'Passeig de Gracia, 43, 08007 Barcelona, Spain',
3,
'11:00',
75,
"Gaudi's dragon house. The facade alone is worth the visit.",
null,
'ChIJ-2VKIcaipBIRKK63H5PYjqQ',
'https://www.casabatllo.es/',
'+34 932 16 03 06',
],
[
t2,
'El Born & Tapas',
41.3856,
2.1825,
'El Born, 08003 Barcelona, Spain',
7,
'20:00',
120,
'Trendy neighborhood with the best tapas bars. Try Cal Pep or El Xampanyet!',
null,
'ChIJNY56dxuipBIRbqjSczmLvIA',
null,
null,
],
];
const t2pIds = t2places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
const t2pIds = t2places.map((p) => Number(insertPlace.run(...p).lastInsertRowid));
// Day 1: Arrival, Beach, El Born
insertAssignment.run(t2days[0], t2pIds[0], 0);
@@ -201,12 +578,28 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
insertBudget.run(t2, 'Food', 'Restaurants & tapas', 300, 2, 'Approx. 75 EUR/day');
insertBudget.run(t2, 'Activities', 'Sagrada Familia + Park Guell + Casa Batllo', 95, 2, 'Online tickets');
insertReservation.run(t2, t2days[1], 'Sagrada Familia Entry', '10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona');
insertReservation.run(
t2,
t2days[1],
'Sagrada Familia Entry',
'10:00',
'SF-2026-11234',
'confirmed',
'activity',
'Eixample, Barcelona',
);
insertMember.run(t2, demoId, adminId);
// --- Trip 3: New York City ---
const trip3 = insertTrip.run(adminId, 'New York City', 'The city that never sleeps — iconic landmarks, world-class food, and Broadway lights.', '2026-09-18', '2026-09-22', 'USD');
const trip3 = insertTrip.run(
adminId,
'New York City',
'The city that never sleeps — iconic landmarks, world-class food, and Broadway lights.',
'2026-09-18',
'2026-09-22',
'USD',
);
const t3 = Number(trip3.lastInsertRowid);
const t3days: number[] = [];
@@ -215,21 +608,189 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
t3days.push(Number(d.lastInsertRowid));
}
const t3places: [number, string, number, number, string, number, string, number, string, string | null, string | null, string | null, string | null][] = [
[t3, 'The Plaza Hotel', 40.7645, -73.9744, '768 5th Ave, New York, NY 10019, USA', 1, '15:00', 60, 'Iconic luxury hotel on Central Park. The lobby alone is worth a visit.', null, 'ChIJYbISlAVYwokRn6ORbSPV0xk', 'https://www.theplazany.com/', '+1 212-759-3000'],
[t3, 'Statue of Liberty', 40.6892, -74.0445, 'Liberty Island, New York, NY 10004, USA', 3, '09:00', 180, 'Book crown access tickets months in advance. Ferry from Battery Park.', null, 'ChIJPTacEpBQwokRKwIlDXelxkA', 'https://www.nps.gov/stli/', '+1 212-363-3200'],
[t3, 'Central Park', 40.7829, -73.9654, 'Central Park, New York, NY 10024, USA', 9, '10:00', 120, 'Bethesda Fountain, Bow Bridge, and Strawberry Fields. Rent bikes!', null, 'ChIJ4zGFAZpYwokRGUGph3Mf37k', 'https://www.centralparknyc.org/', null],
[t3, 'Times Square', 40.7580, -73.9855, 'Manhattan, NY 10036, USA', 3, '19:00', 60, 'The crossroads of the world. Best experienced at night with all the lights.', null, 'ChIJmQJIxlVYwokRLgeuocVOGVU', 'https://www.timessquarenyc.org/', null],
[t3, 'Empire State Building', 40.7484, -73.9857, '350 5th Ave, New York, NY 10118, USA', 3, '11:00', 90, '86th floor observation deck. Go at sunset for the best views.', null, 'ChIJaXQRs6lZwokRY6EFpJnhNNE', 'https://www.esbnyc.com/', '+1 212-736-3100'],
[t3, 'Brooklyn Bridge', 40.7061, -73.9969, 'Brooklyn Bridge, New York, NY 10038, USA', 3, '16:00', 75, 'Walk from Manhattan to Brooklyn. DUMBO has great pizza and views.', null, 'ChIJK3vOQyNawokRXEYwET2GUtY', null, null],
[t3, 'The Metropolitan Museum of Art', 40.7794, -73.9632, '1000 5th Ave, New York, NY 10028, USA', 3, '10:00', 180, 'One of the world\'s greatest art museums. Could spend days here.', null, 'ChIJb8Jg766MwokR1YWG0nV7k-E', 'https://www.metmuseum.org/', '+1 212-535-7710'],
[t3, 'Joe\'s Pizza', 40.7309, -73.9969, '7 Carmine St, New York, NY 10014, USA', 2, '13:00', 30, 'New York\'s most famous pizza slice. Cash only, always a line, always worth it.', null, 'ChIJrfCL1IZZwokRwO3NKN22ZBc', 'http://www.joespizzanyc.com/', '+1 212-366-1182'],
[t3, 'Top of the Rock', 40.7593, -73.9794, '30 Rockefeller Plaza, New York, NY 10112, USA', 3, '17:30', 60, 'Better views than Empire State because you can SEE the Empire State.', null, 'ChIJ_y2Fb1JYwokRT_iGzhTLdBo', 'https://www.topoftherocknyc.com/', '+1 212-698-2000'],
[t3, 'Chelsea Market', 40.7424, -74.0061, '75 9th Ave, New York, NY 10011, USA', 2, '12:00', 90, 'Food hall in a converted factory. Lobster rolls, tacos, doughnuts, and more.', null, 'ChIJw2FNFyZZwokRcP9th_vIbkE', 'https://www.chelseamarket.com/', null],
[t3, 'Broadway Show', 40.7590, -73.9845, 'Broadway, Manhattan, NY 10019, USA', 6, '20:00', 150, 'Can\'t visit NYC without seeing a show. Book TKTS booth for discounts.', null, 'ChIJMYQhxFtYwokR7cJBcNqfKDY', null, null],
const t3places: [
number,
string,
number,
number,
string,
number,
string,
number,
string,
string | null,
string | null,
string | null,
string | null,
][] = [
[
t3,
'The Plaza Hotel',
40.7645,
-73.9744,
'768 5th Ave, New York, NY 10019, USA',
1,
'15:00',
60,
'Iconic luxury hotel on Central Park. The lobby alone is worth a visit.',
null,
'ChIJYbISlAVYwokRn6ORbSPV0xk',
'https://www.theplazany.com/',
'+1 212-759-3000',
],
[
t3,
'Statue of Liberty',
40.6892,
-74.0445,
'Liberty Island, New York, NY 10004, USA',
3,
'09:00',
180,
'Book crown access tickets months in advance. Ferry from Battery Park.',
null,
'ChIJPTacEpBQwokRKwIlDXelxkA',
'https://www.nps.gov/stli/',
'+1 212-363-3200',
],
[
t3,
'Central Park',
40.7829,
-73.9654,
'Central Park, New York, NY 10024, USA',
9,
'10:00',
120,
'Bethesda Fountain, Bow Bridge, and Strawberry Fields. Rent bikes!',
null,
'ChIJ4zGFAZpYwokRGUGph3Mf37k',
'https://www.centralparknyc.org/',
null,
],
[
t3,
'Times Square',
40.758,
-73.9855,
'Manhattan, NY 10036, USA',
3,
'19:00',
60,
'The crossroads of the world. Best experienced at night with all the lights.',
null,
'ChIJmQJIxlVYwokRLgeuocVOGVU',
'https://www.timessquarenyc.org/',
null,
],
[
t3,
'Empire State Building',
40.7484,
-73.9857,
'350 5th Ave, New York, NY 10118, USA',
3,
'11:00',
90,
'86th floor observation deck. Go at sunset for the best views.',
null,
'ChIJaXQRs6lZwokRY6EFpJnhNNE',
'https://www.esbnyc.com/',
'+1 212-736-3100',
],
[
t3,
'Brooklyn Bridge',
40.7061,
-73.9969,
'Brooklyn Bridge, New York, NY 10038, USA',
3,
'16:00',
75,
'Walk from Manhattan to Brooklyn. DUMBO has great pizza and views.',
null,
'ChIJK3vOQyNawokRXEYwET2GUtY',
null,
null,
],
[
t3,
'The Metropolitan Museum of Art',
40.7794,
-73.9632,
'1000 5th Ave, New York, NY 10028, USA',
3,
'10:00',
180,
"One of the world's greatest art museums. Could spend days here.",
null,
'ChIJb8Jg766MwokR1YWG0nV7k-E',
'https://www.metmuseum.org/',
'+1 212-535-7710',
],
[
t3,
"Joe's Pizza",
40.7309,
-73.9969,
'7 Carmine St, New York, NY 10014, USA',
2,
'13:00',
30,
"New York's most famous pizza slice. Cash only, always a line, always worth it.",
null,
'ChIJrfCL1IZZwokRwO3NKN22ZBc',
'http://www.joespizzanyc.com/',
'+1 212-366-1182',
],
[
t3,
'Top of the Rock',
40.7593,
-73.9794,
'30 Rockefeller Plaza, New York, NY 10112, USA',
3,
'17:30',
60,
'Better views than Empire State because you can SEE the Empire State.',
null,
'ChIJ_y2Fb1JYwokRT_iGzhTLdBo',
'https://www.topoftherocknyc.com/',
'+1 212-698-2000',
],
[
t3,
'Chelsea Market',
40.7424,
-74.0061,
'75 9th Ave, New York, NY 10011, USA',
2,
'12:00',
90,
'Food hall in a converted factory. Lobster rolls, tacos, doughnuts, and more.',
null,
'ChIJw2FNFyZZwokRcP9th_vIbkE',
'https://www.chelseamarket.com/',
null,
],
[
t3,
'Broadway Show',
40.759,
-73.9845,
'Broadway, Manhattan, NY 10019, USA',
6,
'20:00',
150,
"Can't visit NYC without seeing a show. Book TKTS booth for discounts.",
null,
'ChIJMYQhxFtYwokR7cJBcNqfKDY',
null,
null,
],
];
const t3pIds = t3places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
const t3pIds = t3places.map((p) => Number(insertPlace.run(...p).lastInsertRowid));
// Day 1: Arrival, Times Square, Broadway
insertAssignment.run(t3days[0], t3pIds[0], 0);
@@ -253,12 +814,16 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
// Packing
const t3packing: [string, number, string, number][] = [
['Passport', 1, 'Documents', 0], ['ESTA confirmation', 1, 'Documents', 1],
['Travel insurance', 0, 'Documents', 2], ['Comfortable sneakers', 0, 'Clothing', 3],
['Light jacket', 0, 'Clothing', 4], ['Portable charger', 0, 'Electronics', 5],
['Camera', 0, 'Electronics', 6], ['Subway card (OMNY)', 0, 'Transport', 7],
['Passport', 1, 'Documents', 0],
['ESTA confirmation', 1, 'Documents', 1],
['Travel insurance', 0, 'Documents', 2],
['Comfortable sneakers', 0, 'Clothing', 3],
['Light jacket', 0, 'Clothing', 4],
['Portable charger', 0, 'Electronics', 5],
['Camera', 0, 'Electronics', 6],
['Subway card (OMNY)', 0, 'Transport', 7],
];
t3packing.forEach(p => insertPacking.run(t3, ...p));
t3packing.forEach((p) => insertPacking.run(t3, ...p));
// Budget
insertBudget.run(t3, 'Accommodation', 'The Plaza Hotel (4 nights)', 2400, 2, 'Park View Room');
@@ -267,9 +832,36 @@ function seedExampleTrips(db: Database.Database, adminId: number, demoId: number
insertBudget.run(t3, 'Activities', 'Statue of Liberty + Empire State + Top of the Rock + Met', 180, 2, 'CityPASS');
insertBudget.run(t3, 'Entertainment', 'Broadway show tickets', 300, 2, 'Hamilton or Wicked');
insertReservation.run(t3, t3days[0], 'The Plaza Hotel Check-in', '15:00', 'PZ-2026-55891', 'confirmed', 'hotel', '768 5th Ave, New York');
insertReservation.run(t3, t3days[0], 'Broadway Show', '20:00', 'BW-HAM-2026-1192', 'pending', 'activity', 'Richard Rodgers Theatre');
insertReservation.run(t3, t3days[1], 'Statue of Liberty Ferry', '08:30', 'SOL-2026-3347', 'confirmed', 'transport', 'Battery Park');
insertReservation.run(
t3,
t3days[0],
'The Plaza Hotel Check-in',
'15:00',
'PZ-2026-55891',
'confirmed',
'hotel',
'768 5th Ave, New York',
);
insertReservation.run(
t3,
t3days[0],
'Broadway Show',
'20:00',
'BW-HAM-2026-1192',
'pending',
'activity',
'Richard Rodgers Theatre',
);
insertReservation.run(
t3,
t3days[1],
'Statue of Liberty Ferry',
'08:30',
'SOL-2026-3347',
'confirmed',
'transport',
'Battery Park',
);
insertMember.run(t3, demoId, adminId);
+25 -21
View File
@@ -1,16 +1,19 @@
import 'reflect-metadata';
import 'dotenv/config';
import path from 'node:path';
import fs from 'node:fs';
import http from 'node:http';
import express from 'express';
import cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import { createApp } from './app';
import { AppModule } from './nest/app.module';
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
import type { INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import 'dotenv/config';
import express from 'express';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import 'reflect-metadata';
// Create upload and data directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
@@ -21,7 +24,7 @@ const avatarsDir = path.join(uploadsDir, 'avatars');
const backupsDir = path.join(__dirname, '../data/backups');
const tmpDir = path.join(__dirname, '../data/tmp');
[uploadsDir, photosDir, filesDir, coversDir, avatarsDir, backupsDir, tmpDir].forEach(dir => {
[uploadsDir, photosDir, filesDir, coversDir, avatarsDir, backupsDir, tmpDir].forEach((dir) => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
@@ -30,9 +33,6 @@ const tmpDir = path.join(__dirname, '../data/tmp');
// everything else falls through to this app via a fallback middleware.
const legacyApp = createApp();
import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
const PORT = Number(process.env.PORT) || 3001;
const HOST = process.env.HOST;
const APP_VERSION: string = process.env.APP_VERSION || (require('../package.json') as { version: string }).version;
@@ -60,7 +60,7 @@ const onListen = () => {
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
'──────────────────────────────────────',
];
banner.forEach(l => console.log(l));
banner.forEach((l) => console.log(l));
sLogInfo(
NEST_PREFIXES.length
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
@@ -68,17 +68,21 @@ const onListen = () => {
);
if (process.env.APP_URL) {
let parsedAppUrl: URL | null = null;
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
try {
parsedAppUrl = new URL(process.env.APP_URL);
} catch {
/* invalid */
}
if (!parsedAppUrl) {
sLogWarn(`APP_URL: "${process.env.APP_URL}" is not a valid URL — it will be ignored.`);
}
const mcpSafe = parsedAppUrl !== null && (
parsedAppUrl.protocol === 'https:' ||
parsedAppUrl.hostname === 'localhost' ||
parsedAppUrl.hostname === '127.0.0.1'
);
const mcpSafe =
parsedAppUrl !== null &&
(parsedAppUrl.protocol === 'https:' ||
parsedAppUrl.hostname === 'localhost' ||
parsedAppUrl.hostname === '127.0.0.1');
if (!mcpSafe) {
sLogWarn(`APP_URL: not MCP-safe (requires https:// or http://localhost) — MCP will use ${resolvedAppUrl}.`);
}
+78 -41
View File
@@ -1,17 +1,18 @@
import { Request, Response } from 'express';
import { randomUUID } from 'crypto';
import { ADDON_IDS } from '../addons';
import { isAddonEnabled } from '../services/adminService';
import { writeAudit, getClientIp } from '../services/auditLog';
import { verifyMcpToken, verifyJwtToken } from '../services/authService';
import { getMcpSafeUrl } from '../services/notifications';
import { getUserByAccessToken } from '../services/oauthService';
import { User } from '../types';
import { registerResources } from './resources';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { registerTools } from './tools';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
import { User } from '../types';
import { verifyMcpToken, verifyJwtToken } from '../services/authService';
import { getUserByAccessToken } from '../services/oauthService';
import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { writeAudit, getClientIp } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
import { randomUUID } from 'crypto';
import { Request, Response } from 'express';
export { revokeUserSessions, revokeUserSessionsForClient };
@@ -90,16 +91,16 @@ The following features are optional and may not be available on every TREK insta
`.trim();
const STATIC_TOKEN_DEPRECATION_NOTICE =
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
'The actual tool result follows — answer the user\'s question as well.';
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
"The actual tool result follows — answer the user's question as well.";
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? '');
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 20;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? '');
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 300; // requests per minute per user
interface RateLimitEntry {
@@ -134,8 +135,16 @@ const sessionSweepInterval = setInterval(() => {
let cleaned = 0;
for (const [sid, session] of sessions) {
if (session.lastActivity < cutoff) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
try {
session.server.close();
} catch {
/* ignore */
}
try {
session.transport.close();
} catch {
/* ignore */
}
sessions.delete(sid);
cleaned++;
}
@@ -155,8 +164,10 @@ sessionSweepInterval.unref();
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
res.set('WWW-Authenticate',
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
res.set(
'WWW-Authenticate',
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`,
);
}
interface VerifyTokenResult {
@@ -174,7 +185,7 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
const spaceIdx = authHeader.indexOf(' ');
if (spaceIdx === -1) return null;
const scheme = authHeader.slice(0, spaceIdx);
const token = authHeader.slice(spaceIdx + 1);
const token = authHeader.slice(spaceIdx + 1);
if (scheme.toLowerCase() !== 'bearer' || !token) return null;
// OAuth 2.1 access token (trekoa_...)
@@ -279,18 +290,18 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
// Create a new per-user MCP server and session
const server = new McpServer(
{
name: 'TREK MCP',
version: '1.0.0',
{
name: 'TREK MCP',
version: '1.0.0',
},
{
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
prompts: { listChanged: true },
},
{
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
prompts: { listChanged: true },
},
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
}
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
},
);
// Per-session closure: fires the deprecation notice once, on the first tool call.
// Tool results are the only mechanism Claude reliably surfaces to the user;
@@ -308,9 +319,19 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, { server, transport, userId: user.id, scopes, clientId, isStaticToken, lastActivity: Date.now() });
sessions.set(sid, {
server,
transport,
userId: user.id,
scopes,
clientId,
isStaticToken,
lastActivity: Date.now(),
});
const authMethod = isStaticToken ? 'static-token' : scopes ? `oauth(${scopes.join(',')})` : 'jwt';
console.log(`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`);
console.log(
`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`,
);
},
onsessionclosed: (sid) => {
sessions.delete(sid);
@@ -332,8 +353,16 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
/** Invalidate all active MCP sessions (call when addon state changes so sessions re-create with updated tools). */
export function invalidateMcpSessions(): void {
for (const [sid, session] of sessions) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
try {
session.server.close();
} catch {
/* ignore */
}
try {
session.transport.close();
} catch {
/* ignore */
}
sessions.delete(sid);
}
console.log('[MCP] All sessions invalidated due to addon state change');
@@ -343,9 +372,17 @@ export function invalidateMcpSessions(): void {
export function closeMcpSessions(): void {
clearInterval(sessionSweepInterval);
for (const [, session] of sessions) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
try {
session.server.close();
} catch {
/* ignore */
}
try {
session.transport.close();
} catch {
/* ignore */
}
}
sessions.clear();
rateLimitMap.clear();
}
}
+181 -160
View File
@@ -1,35 +1,40 @@
import type { Response } from 'express';
import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider';
import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider';
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients';
import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors';
import { db } from '../db/database';
import { writeAudit } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
import {
createOAuthClient,
consumeAuthCode,
issueTokens,
refreshTokens,
revokeToken as serviceRevokeToken,
verifyPKCE,
getUserByAccessToken,
createOAuthClient,
consumeAuthCode,
issueTokens,
refreshTokens,
revokeToken as serviceRevokeToken,
verifyPKCE,
getUserByAccessToken,
} from '../services/oauthService';
import { ALL_SCOPES } from './scopes';
import { getMcpSafeUrl } from '../services/notifications';
import { writeAudit } from '../services/auditLog';
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients';
import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors';
import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider';
import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import type {
OAuthClientInformationFull,
OAuthTokenRevocationRequest,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth';
import type { Response } from 'express';
// ---------------------------------------------------------------------------
// DB row type (mirrors oauthService.ts)
// ---------------------------------------------------------------------------
interface OAuthClientRow {
client_id: string;
name: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
is_public: number; // 0 | 1
created_via: string;
client_id: string;
name: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
is_public: number; // 0 | 1
created_via: string;
}
// ---------------------------------------------------------------------------
@@ -37,23 +42,36 @@ interface OAuthClientRow {
// ---------------------------------------------------------------------------
const DANGEROUS_SCHEMES = new Set([
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
'javascript:',
'data:',
'vbscript:',
'file:',
'blob:',
'about:',
'chrome:',
'chrome-extension:',
]);
function assertValidRedirectUris(uris: string[]): void {
for (const u of uris) {
let url: URL;
try { url = new URL(u); } catch {
throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`);
}
if (DANGEROUS_SCHEMES.has(url.protocol))
throw new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${u}`);
if (url.protocol === 'https:') continue;
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) continue;
const scheme = url.protocol.slice(0, -1);
if (/^[a-z][a-z0-9+.-]*$/i.test(scheme) && scheme.includes('.')) continue;
throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme');
for (const u of uris) {
let url: URL;
try {
url = new URL(u);
} catch {
throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`);
}
if (DANGEROUS_SCHEMES.has(url.protocol))
throw new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${u}`);
if (url.protocol === 'https:') continue;
if (
url.protocol === 'http:' &&
(url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')
)
continue;
const scheme = url.protocol.slice(0, -1);
if (/^[a-z][a-z0-9+.-]*$/i.test(scheme) && scheme.includes('.')) continue;
throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme');
}
}
// ---------------------------------------------------------------------------
@@ -61,15 +79,15 @@ function assertValidRedirectUris(uris: string[]): void {
// ---------------------------------------------------------------------------
function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
return {
client_id: row.client_id,
client_name: row.name,
redirect_uris: JSON.parse(row.redirect_uris) as string[],
scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '),
token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
};
return {
client_id: row.client_id,
client_name: row.name,
redirect_uris: JSON.parse(row.redirect_uris) as string[],
scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '),
token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
};
}
// ---------------------------------------------------------------------------
@@ -77,43 +95,46 @@ function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
// ---------------------------------------------------------------------------
export const trekClientsStore: OAuthRegisteredClientsStore = {
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
const row = db.prepare(
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?'
).get(clientId) as OAuthClientRow | undefined;
return row ? rowToInfo(row) : undefined;
},
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
const row = db
.prepare(
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?',
)
.get(clientId) as OAuthClientRow | undefined;
return row ? rowToInfo(row) : undefined;
},
async registerClient(
metadata: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
): Promise<OAuthClientInformationFull> {
const uris = metadata.redirect_uris as string[];
assertValidRedirectUris(uris);
async registerClient(
metadata: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
): Promise<OAuthClientInformationFull> {
const uris = metadata.redirect_uris as string[];
assertValidRedirectUris(uris);
const isPublic = metadata.token_endpoint_auth_method === 'none';
const name = (typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '').slice(0, 100) || 'MCP Client';
const isPublic = metadata.token_endpoint_auth_method === 'none';
const name =
(typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '').slice(0, 100) || 'MCP Client';
// When scope is absent (ChatGPT DCR), default to all scopes.
// The user still grants only what they approve at the consent screen.
const rawScopes = metadata.scope ? metadata.scope.split(' ') : ALL_SCOPES;
const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s));
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
// When scope is absent (ChatGPT DCR), default to all scopes.
// The user still grants only what they approve at the consent screen.
const rawScopes = metadata.scope ? metadata.scope.split(' ') : ALL_SCOPES;
const scopes = rawScopes.filter((s) => (ALL_SCOPES as string[]).includes(s));
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
if (result.error) throw new InvalidClientMetadataError(result.error);
const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
if (result.error) throw new InvalidClientMetadataError(result.error);
const c = result.client!;
return {
client_id: c.client_id as string,
client_name: c.name as string,
redirect_uris: c.redirect_uris as string[],
scope: (c.allowed_scopes as string[]).join(' '),
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
...(c.client_secret ? { client_secret: c.client_secret as string, client_secret_expires_at: 0 } : {}),
};
},
const c = result.client;
return {
client_id: c.client_id as string,
client_name: c.name as string,
redirect_uris: c.redirect_uris as string[],
scope: (c.allowed_scopes as string[]).join(' '),
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
...(c.client_secret ? { client_secret: c.client_secret as string, client_secret_expires_at: 0 } : {}),
};
},
};
// ---------------------------------------------------------------------------
@@ -121,101 +142,101 @@ export const trekClientsStore: OAuthRegisteredClientsStore = {
// ---------------------------------------------------------------------------
export const trekOAuthProvider: OAuthServerProvider = {
get clientsStore() { return trekClientsStore; },
get clientsStore() {
return trekClientsStore;
},
// Redirects browser to the SPA consent page with OAuth params forwarded.
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
// Redirects browser to the SPA consent page with OAuth params forwarded.
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
if (resource !== mcpResource) {
const url = new URL(params.redirectUri);
url.searchParams.set('error', 'invalid_target');
url.searchParams.set('error_description', 'Requested resource must be the TREK MCP endpoint');
if (params.state) url.searchParams.set('state', params.state);
res.redirect(302, url.toString());
return;
}
if (resource !== mcpResource) {
const url = new URL(params.redirectUri);
url.searchParams.set('error', 'invalid_target');
url.searchParams.set('error_description', 'Requested resource must be the TREK MCP endpoint');
if (params.state) url.searchParams.set('state', params.state);
res.redirect(302, url.toString());
return;
}
const qs = new URLSearchParams({
client_id: client.client_id,
redirect_uri: params.redirectUri,
scope: params.scopes.join(' '),
code_challenge: params.codeChallenge,
code_challenge_method: 'S256',
});
if (params.state) qs.set('state', params.state);
if (params.resource) qs.set('resource', params.resource.href);
const qs = new URLSearchParams({
client_id: client.client_id,
redirect_uri: params.redirectUri,
scope: params.scopes.join(' '),
code_challenge: params.codeChallenge,
code_challenge_method: 'S256',
});
if (params.state) qs.set('state', params.state);
if (params.resource) qs.set('resource', params.resource.href);
const base = getMcpSafeUrl().replace(/\/+$/, '');
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
},
const base = getMcpSafeUrl().replace(/\/+$/, '');
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
},
// Not called because skipLocalPkceValidation = true.
// PKCE verification is done inline in exchangeAuthorizationCode.
skipLocalPkceValidation: true,
// Not called because skipLocalPkceValidation = true.
// PKCE verification is done inline in exchangeAuthorizationCode.
skipLocalPkceValidation: true,
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise<string> {
throw new ServerError('PKCE validation is handled by the provider directly');
},
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise<string> {
throw new ServerError('PKCE validation is handled by the provider directly');
},
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
code: string,
codeVerifier?: string,
redirectUri?: string,
resource?: URL,
): Promise<OAuthTokens> {
const pending = consumeAuthCode(code);
if (!pending || pending.clientId !== client.client_id)
throw new Error('Authorization grant is invalid.');
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
code: string,
codeVerifier?: string,
redirectUri?: string,
resource?: URL,
): Promise<OAuthTokens> {
const pending = consumeAuthCode(code);
if (!pending || pending.clientId !== client.client_id) throw new Error('Authorization grant is invalid.');
if (redirectUri && pending.redirectUri !== redirectUri)
throw new Error('Authorization grant is invalid.');
if (redirectUri && pending.redirectUri !== redirectUri) throw new Error('Authorization grant is invalid.');
const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null;
if (pending.resource && resourceStr && pending.resource !== resourceStr)
throw new Error('Authorization grant is invalid.');
const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null;
if (pending.resource && resourceStr && pending.resource !== resourceStr)
throw new Error('Authorization grant is invalid.');
if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge))
throw new Error('Authorization grant is invalid.');
if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge))
throw new Error('Authorization grant is invalid.');
const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({
userId: pending.userId,
action: 'oauth.token.issue',
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
ip: null,
});
return tokens;
},
const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({
userId: pending.userId,
action: 'oauth.token.issue',
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
ip: null,
});
return tokens;
},
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
_scopes?: string[],
_resource?: URL,
): Promise<OAuthTokens> {
const result = refreshTokens(refreshToken, client.client_id, client.client_secret, null);
if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired');
return result.tokens!;
},
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
_scopes?: string[],
_resource?: URL,
): Promise<OAuthTokens> {
const result = refreshTokens(refreshToken, client.client_id, client.client_secret, null);
if (result.error)
throw new Error(
result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
);
return result.tokens;
},
async verifyAccessToken(token: string): Promise<AuthInfo> {
const info = getUserByAccessToken(token);
if (!info) throw new Error('Invalid or expired token');
return {
token,
clientId: info.clientId,
scopes: info.scopes,
extra: { user: info.user },
};
},
async verifyAccessToken(token: string): Promise<AuthInfo> {
const info = getUserByAccessToken(token);
if (!info) throw new Error('Invalid or expired token');
return {
token,
clientId: info.clientId,
scopes: info.scopes,
extra: { user: info.user },
};
},
async revokeToken(
client: OAuthClientInformationFull,
request: OAuthTokenRevocationRequest,
): Promise<void> {
serviceRevokeToken(request.token, client.client_id, undefined, null);
},
};
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
serviceRevokeToken(request.token, client.client_id, undefined, null);
},
};
+293 -239
View File
@@ -1,22 +1,33 @@
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import { canAccessTrip } from '../db/database';
import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService';
import { listDays, listAccommodations } from '../services/dayService';
import { listPlaces } from '../services/placeService';
import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../services/budgetService';
import { listItems as listPackingItems, listBags } from '../services/packingService';
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';
import { isAddonEnabled, getCollabFeatures } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { canAccessTrip } from '../db/database';
import { isAddonEnabled, getCollabFeatures } from '../services/adminService';
import {
listBucketList,
listVisitedCountries,
getStats as getAtlasStats,
listManuallyVisitedRegions,
} from '../services/atlasService';
import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../services/budgetService';
import { listCategories } from '../services/categoryService';
import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService';
import { listNotes as listDayNotes } from '../services/dayNoteService';
import { listDays, listAccommodations } from '../services/dayService';
import { getNotifications } from '../services/inAppNotifications';
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
import { listItems as listPackingItems, listBags } from '../services/packingService';
import { listPlaces } from '../services/placeService';
import { listReservations } from '../services/reservationService';
import { listItems as listTodoItems } from '../services/todoService';
import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService';
import {
getActivePlanId,
getActivePlan,
getPlanData,
getEntries as getVacayEntries,
getHolidays,
} from '../services/vacayService';
import { canRead, canReadTrips } from './scopes';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
@@ -25,277 +36,317 @@ function parseId(value: string | string[]): number | null {
function accessDenied(uri: string) {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify({ error: 'Trip not found or access denied' }),
}],
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify({ error: 'Trip not found or access denied' }),
},
],
};
}
function scopeDenied(uri: string) {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify({ error: 'Insufficient OAuth scope to access this resource' }),
}],
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify({ error: 'Insufficient OAuth scope to access this resource' }),
},
],
};
}
function jsonContent(uri: string, data: unknown) {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
}],
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
],
};
}
export function registerResources(server: McpServer, userId: number, scopes: string[] | null): void {
// List all accessible trips
if (canReadTrips(scopes)) server.registerResource(
'trips',
'trek://trips',
{ description: 'All trips the user owns or is a member of', mimeType: 'application/json' },
async (uri) => {
const trips = listTrips(userId, 0);
return jsonContent(uri.href, trips);
}
);
if (canReadTrips(scopes))
server.registerResource(
'trips',
'trek://trips',
{ description: 'All trips the user owns or is a member of', mimeType: 'application/json' },
async (uri) => {
const trips = listTrips(userId, 0);
return jsonContent(uri.href, trips);
},
);
// Single trip detail
if (canReadTrips(scopes)) server.registerResource(
'trip',
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
{ description: 'A single trip with metadata and member count', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const trip = getTrip(id, userId);
return jsonContent(uri.href, trip);
}
);
if (canReadTrips(scopes))
server.registerResource(
'trip',
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
{ description: 'A single trip with metadata and member count', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const trip = getTrip(id, userId);
return jsonContent(uri.href, trip);
},
);
// Days with assigned places
if (canReadTrips(scopes)) server.registerResource(
'trip-days',
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
{ description: 'Days of a trip with their assigned places', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
if (canReadTrips(scopes))
server.registerResource(
'trip-days',
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
{ description: 'Days of a trip with their assigned places', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const { days } = listDays(id);
return jsonContent(uri.href, days);
}
);
const { days } = listDays(id);
return jsonContent(uri.href, days);
},
);
// Places in a trip
if (canRead(scopes, 'places')) server.registerResource(
'trip-places',
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
{ description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null;
const places = listPlaces(String(id), { assignment: assignment ?? undefined });
return jsonContent(uri.href, places);
}
);
if (canRead(scopes, 'places'))
server.registerResource(
'trip-places',
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
{
description:
'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)',
mimeType: 'application/json',
},
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null;
const places = listPlaces(String(id), { assignment: assignment ?? undefined });
return jsonContent(uri.href, places);
},
);
// Budget items
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
'trip-budget',
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
{ description: 'Budget and expense items for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const items = listBudgetItems(id);
return jsonContent(uri.href, items);
}
);
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget'))
server.registerResource(
'trip-budget',
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
{ description: 'Budget and expense items for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const items = listBudgetItems(id);
return jsonContent(uri.href, items);
},
);
// Packing checklist
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
'trip-packing',
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
{ description: 'Packing checklist for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const items = listPackingItems(id);
return jsonContent(uri.href, items);
}
);
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing'))
server.registerResource(
'trip-packing',
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
{ description: 'Packing checklist for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const items = listPackingItems(id);
return jsonContent(uri.href, items);
},
);
// Reservations (flights, hotels, restaurants)
if (canRead(scopes, 'reservations')) server.registerResource(
'trip-reservations',
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
{ description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const reservations = listReservations(id);
return jsonContent(uri.href, reservations);
}
);
if (canRead(scopes, 'reservations'))
server.registerResource(
'trip-reservations',
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
{ description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const reservations = listReservations(id);
return jsonContent(uri.href, reservations);
},
);
// Day notes
if (canReadTrips(scopes)) server.registerResource(
'day-notes',
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
{ description: 'Notes for a specific day in a trip', mimeType: 'application/json' },
async (uri, { tripId, dayId }) => {
const tId = parseId(tripId);
const dId = parseId(dayId);
if (tId === null || dId === null || !canAccessTrip(tId, userId)) return accessDenied(uri.href);
const notes = listDayNotes(dId, tId);
return jsonContent(uri.href, notes);
}
);
if (canReadTrips(scopes))
server.registerResource(
'day-notes',
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
{ description: 'Notes for a specific day in a trip', mimeType: 'application/json' },
async (uri, { tripId, dayId }) => {
const tId = parseId(tripId);
const dId = parseId(dayId);
if (tId === null || dId === null || !canAccessTrip(tId, userId)) return accessDenied(uri.href);
const notes = listDayNotes(dId, tId);
return jsonContent(uri.href, notes);
},
);
// Accommodations (hotels, rentals) per trip
if (canReadTrips(scopes)) server.registerResource(
'trip-accommodations',
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const accommodations = listAccommodations(id);
return jsonContent(uri.href, accommodations);
}
);
if (canReadTrips(scopes))
server.registerResource(
'trip-accommodations',
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
{
description: 'Accommodations (hotels, rentals) for a trip with check-in/out details',
mimeType: 'application/json',
},
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const accommodations = listAccommodations(id);
return jsonContent(uri.href, accommodations);
},
);
// Trip members (owner + collaborators)
if (canReadTrips(scopes)) server.registerResource(
'trip-members',
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
{ description: 'Owner and collaborators of a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const ownerRow = getTripOwner(id);
if (!ownerRow) return accessDenied(uri.href);
const { owner, members } = listMembers(id, ownerRow.user_id);
return jsonContent(uri.href, { owner, members });
}
);
if (canReadTrips(scopes))
server.registerResource(
'trip-members',
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
{ description: 'Owner and collaborators of a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const ownerRow = getTripOwner(id);
if (!ownerRow) return accessDenied(uri.href);
const { owner, members } = listMembers(id, ownerRow.user_id);
return jsonContent(uri.href, { owner, members });
},
);
// Collab notes for a trip
const collabFeatures = isAddonEnabled(ADDON_IDS.COLLAB) ? getCollabFeatures() : null;
if (collabFeatures?.notes && canRead(scopes, 'collab')) server.registerResource(
'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const notes = listCollabNotes(id);
return jsonContent(uri.href, notes);
}
);
if (collabFeatures?.notes && canRead(scopes, 'collab'))
server.registerResource(
'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const notes = listCollabNotes(id);
return jsonContent(uri.href, notes);
},
);
// 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);
}
);
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);
},
);
// 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' },
{
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);
}
},
);
// User's bucket list
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource(
'bucket-list',
'trek://bucket-list',
{ description: 'Your personal travel bucket list', mimeType: 'application/json' },
async (uri) => {
const items = listBucketList(userId);
return jsonContent(uri.href, items);
}
);
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas'))
server.registerResource(
'bucket-list',
'trek://bucket-list',
{ description: 'Your personal travel bucket list', mimeType: 'application/json' },
async (uri) => {
const items = listBucketList(userId);
return jsonContent(uri.href, items);
},
);
// User's visited countries
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource(
'visited-countries',
'trek://visited-countries',
{ description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' },
async (uri) => {
const countries = listVisitedCountries(userId);
return jsonContent(uri.href, countries);
}
);
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas'))
server.registerResource(
'visited-countries',
'trek://visited-countries',
{ description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' },
async (uri) => {
const countries = listVisitedCountries(userId);
return jsonContent(uri.href, countries);
},
);
// Budget per-person summary
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
'trip-budget-per-person',
new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }),
{ description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const summary = getPerPersonSummary(id);
return jsonContent(uri.href, summary);
}
);
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget'))
server.registerResource(
'trip-budget-per-person',
new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }),
{
description: 'Per-person budget summary for a trip (total spent per member, split breakdown)',
mimeType: 'application/json',
},
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const summary = getPerPersonSummary(id);
return jsonContent(uri.href, summary);
},
);
// Budget settlement
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
'trip-budget-settlement',
new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }),
{ description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const settlement = calculateSettlement(id);
return jsonContent(uri.href, settlement);
}
);
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget'))
server.registerResource(
'trip-budget-settlement',
new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }),
{ description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const settlement = calculateSettlement(id);
return jsonContent(uri.href, settlement);
},
);
// Packing bags
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
'trip-packing-bags',
new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }),
{ description: 'All packing bags for a trip with their members', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const bags = listBags(id);
return jsonContent(uri.href, bags);
}
);
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing'))
server.registerResource(
'trip-packing-bags',
new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }),
{ description: 'All packing bags for a trip with their members', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const bags = listBags(id);
return jsonContent(uri.href, bags);
},
);
// In-app notifications
if (canRead(scopes, 'notifications')) server.registerResource(
'notifications-in-app',
'trek://notifications/in-app',
{ description: "The current user's in-app notifications (most recent 50, unread first)", mimeType: 'application/json' },
async (uri) => {
const result = getNotifications(userId, { limit: 50 });
return jsonContent(uri.href, result);
}
);
if (canRead(scopes, 'notifications'))
server.registerResource(
'notifications-in-app',
'trek://notifications/in-app',
{
description: "The current user's in-app notifications (most recent 50, unread first)",
mimeType: 'application/json',
},
async (uri) => {
const result = getNotifications(userId, { limit: 50 });
return jsonContent(uri.href, result);
},
);
// Atlas stats and regions (addon-gated)
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) {
@@ -306,7 +357,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
async (uri) => {
const stats = await getAtlasStats(userId);
return jsonContent(uri.href, stats);
}
},
);
server.registerResource(
@@ -316,7 +367,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
async (uri) => {
const regions = listManuallyVisitedRegions(userId);
return jsonContent(uri.href, regions);
}
},
);
}
@@ -331,7 +382,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const polls = listPolls(id);
return jsonContent(uri.href, polls);
}
},
);
}
@@ -346,7 +397,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const messages = listMessages(id);
return jsonContent(uri.href, messages);
}
},
);
}
@@ -355,11 +406,14 @@ export function registerResources(server: McpServer, userId: number, scopes: str
server.registerResource(
'vacay-plan',
'trek://vacay/plan',
{ description: "Full snapshot of the user's active vacation plan (members, years, settings)", mimeType: 'application/json' },
{
description: "Full snapshot of the user's active vacation plan (members, years, settings)",
mimeType: 'application/json',
},
async (uri) => {
const plan = getPlanData(userId);
return jsonContent(uri.href, plan);
}
},
);
server.registerResource(
@@ -370,7 +424,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
const planId = getActivePlanId(userId);
const entries = getVacayEntries(planId, Array.isArray(year) ? year[0] : year);
return jsonContent(uri.href, entries);
}
},
);
server.registerResource(
@@ -383,7 +437,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
const yearStr = Array.isArray(year) ? year[0] : year;
const result = await getHolidays(yearStr, plan.holidays_region);
return jsonContent(uri.href, result.data ?? []);
}
},
);
}
@@ -396,7 +450,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
async (uri) => {
const journeys = listJourneys(userId);
return jsonContent(uri.href, journeys);
}
},
);
server.registerResource(
@@ -409,7 +463,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
const journey = getJourneyFull(id, userId);
if (!journey) return accessDenied(uri.href);
return jsonContent(uri.href, journey);
}
},
);
server.registerResource(
@@ -423,7 +477,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
if (!j) return accessDenied(uri.href);
const entries = listEntries(id, userId);
return jsonContent(uri.href, entries);
}
},
);
server.registerResource(
@@ -436,7 +490,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
const j = getJourneyFull(id, userId);
if (!j) return accessDenied(uri.href);
return jsonContent(uri.href, (j as any).contributors ?? []);
}
},
);
}
}
+158 -58
View File
@@ -3,38 +3,38 @@
// ---------------------------------------------------------------------------
export const SCOPES = {
TRIPS_READ: 'trips:read',
TRIPS_WRITE: 'trips:write',
TRIPS_DELETE: 'trips:delete',
TRIPS_SHARE: 'trips:share',
PLACES_READ: 'places:read',
PLACES_WRITE: 'places:write',
ATLAS_READ: 'atlas:read',
ATLAS_WRITE: 'atlas:write',
PACKING_READ: 'packing:read',
PACKING_WRITE: 'packing:write',
TODOS_READ: 'todos:read',
TODOS_WRITE: 'todos:write',
BUDGET_READ: 'budget:read',
BUDGET_WRITE: 'budget:write',
RESERVATIONS_READ: 'reservations:read',
RESERVATIONS_WRITE: 'reservations:write',
COLLAB_READ: 'collab:read',
COLLAB_WRITE: 'collab:write',
NOTIFICATIONS_READ: 'notifications:read',
TRIPS_READ: 'trips:read',
TRIPS_WRITE: 'trips:write',
TRIPS_DELETE: 'trips:delete',
TRIPS_SHARE: 'trips:share',
PLACES_READ: 'places:read',
PLACES_WRITE: 'places:write',
ATLAS_READ: 'atlas:read',
ATLAS_WRITE: 'atlas:write',
PACKING_READ: 'packing:read',
PACKING_WRITE: 'packing:write',
TODOS_READ: 'todos:read',
TODOS_WRITE: 'todos:write',
BUDGET_READ: 'budget:read',
BUDGET_WRITE: 'budget:write',
RESERVATIONS_READ: 'reservations:read',
RESERVATIONS_WRITE: 'reservations:write',
COLLAB_READ: 'collab:read',
COLLAB_WRITE: 'collab:write',
NOTIFICATIONS_READ: 'notifications:read',
NOTIFICATIONS_WRITE: 'notifications:write',
VACAY_READ: 'vacay:read',
VACAY_WRITE: 'vacay:write',
GEO_READ: 'geo:read',
WEATHER_READ: 'weather:read',
JOURNEY_READ: 'journey:read',
JOURNEY_WRITE: 'journey:write',
JOURNEY_SHARE: 'journey:share',
VACAY_READ: 'vacay:read',
VACAY_WRITE: 'vacay:write',
GEO_READ: 'geo:read',
WEATHER_READ: 'weather:read',
JOURNEY_READ: 'journey:read',
JOURNEY_WRITE: 'journey:write',
JOURNEY_SHARE: 'journey:share',
} as const;
export type Scope = typeof SCOPES[keyof typeof SCOPES];
export type Scope = (typeof SCOPES)[keyof typeof SCOPES];
export const ALL_SCOPES: Scope[] = Object.values(SCOPES) as Scope[];
export const ALL_SCOPES: Scope[] = Object.values(SCOPES);
export interface ScopeInfo {
label: string;
@@ -43,33 +43,133 @@ export interface ScopeInfo {
}
export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
'trips:read': { label: 'View trips & itineraries', description: 'Read trips, days, day notes, and members', group: 'Trips' },
'trips:write': { label: 'Edit trips & itineraries', description: 'Create and update trips, days, notes, and manage members', group: 'Trips' },
'trips:delete': { label: 'Delete trips', description: 'Permanently delete entire trips — this action is irreversible', group: 'Trips' },
'trips:share': { label: 'Manage share links', description: 'Create, update, and revoke public share links for trips', group: 'Trips' },
'places:read': { label: 'View places & map data', description: 'Read places, day assignments, tags, and categories', group: 'Places' },
'places:write': { label: 'Manage places', description: 'Create, update, and delete places, assignments, and tags', group: 'Places' },
'atlas:read': { label: 'View Atlas', description: 'Read visited countries, regions, and bucket list', group: 'Atlas' },
'atlas:write': { label: 'Manage Atlas', description: 'Mark countries and regions visited, manage bucket list', group: 'Atlas' },
'packing:read': { label: 'View packing lists', description: 'Read packing items, bags, and category assignees', group: 'Packing' },
'packing:write': { label: 'Manage packing lists', description: 'Add, update, delete, toggle, and reorder packing items and bags', group: 'Packing' },
'todos:read': { label: 'View to-do lists', description: 'Read trip to-do items and category assignees', group: 'To-dos' },
'todos:write': { label: 'Manage to-do lists', description: 'Create, update, toggle, delete, and reorder to-do items', group: 'To-dos' },
'budget:read': { label: 'View budget', description: 'Read budget items and expense breakdown', group: 'Budget' },
'budget:write': { label: 'Manage budget', description: 'Create, update, and delete budget items', group: 'Budget' },
'reservations:read': { label: 'View reservations', description: 'Read reservations and accommodation details', group: 'Reservations' },
'reservations:write': { label: 'Manage reservations', description: 'Create, update, delete, and reorder reservations', group: 'Reservations' },
'collab:read': { label: 'View collaboration', description: 'Read collab notes, polls, and messages', group: 'Collaboration' },
'collab:write': { label: 'Manage collaboration', description: 'Create, update, and delete collab notes, polls, and messages', group: 'Collaboration' },
'notifications:read': { label: 'View notifications', description: 'Read in-app notifications and unread counts', group: 'Notifications' },
'notifications:write': { label: 'Manage notifications', description: 'Mark notifications as read and respond to them', group: 'Notifications' },
'vacay:read': { label: 'View vacation plans', description: 'Read vacation planning data, entries, and stats', group: 'Vacation' },
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
'geo:read': { label: 'Maps & geocoding', description: 'Search locations, resolve map URLs, and reverse geocode coordinates', group: 'Geo' },
'weather:read': { label: 'Weather forecasts', description: 'Fetch weather forecasts for trip locations and dates', group: 'Weather' },
'journey:read': { label: 'View journeys', description: 'Read journeys, entries, and contributor list', group: 'Journey' },
'journey:write': { label: 'Manage journeys', description: 'Create, update, and delete journeys and their entries', group: 'Journey' },
'journey:share': { label: 'Manage journey links', description: 'Create, update, and revoke public share links for journeys', group: 'Journey' },
'trips:read': {
label: 'View trips & itineraries',
description: 'Read trips, days, day notes, and members',
group: 'Trips',
},
'trips:write': {
label: 'Edit trips & itineraries',
description: 'Create and update trips, days, notes, and manage members',
group: 'Trips',
},
'trips:delete': {
label: 'Delete trips',
description: 'Permanently delete entire trips — this action is irreversible',
group: 'Trips',
},
'trips:share': {
label: 'Manage share links',
description: 'Create, update, and revoke public share links for trips',
group: 'Trips',
},
'places:read': {
label: 'View places & map data',
description: 'Read places, day assignments, tags, and categories',
group: 'Places',
},
'places:write': {
label: 'Manage places',
description: 'Create, update, and delete places, assignments, and tags',
group: 'Places',
},
'atlas:read': {
label: 'View Atlas',
description: 'Read visited countries, regions, and bucket list',
group: 'Atlas',
},
'atlas:write': {
label: 'Manage Atlas',
description: 'Mark countries and regions visited, manage bucket list',
group: 'Atlas',
},
'packing:read': {
label: 'View packing lists',
description: 'Read packing items, bags, and category assignees',
group: 'Packing',
},
'packing:write': {
label: 'Manage packing lists',
description: 'Add, update, delete, toggle, and reorder packing items and bags',
group: 'Packing',
},
'todos:read': {
label: 'View to-do lists',
description: 'Read trip to-do items and category assignees',
group: 'To-dos',
},
'todos:write': {
label: 'Manage to-do lists',
description: 'Create, update, toggle, delete, and reorder to-do items',
group: 'To-dos',
},
'budget:read': { label: 'View budget', description: 'Read budget items and expense breakdown', group: 'Budget' },
'budget:write': { label: 'Manage budget', description: 'Create, update, and delete budget items', group: 'Budget' },
'reservations:read': {
label: 'View reservations',
description: 'Read reservations and accommodation details',
group: 'Reservations',
},
'reservations:write': {
label: 'Manage reservations',
description: 'Create, update, delete, and reorder reservations',
group: 'Reservations',
},
'collab:read': {
label: 'View collaboration',
description: 'Read collab notes, polls, and messages',
group: 'Collaboration',
},
'collab:write': {
label: 'Manage collaboration',
description: 'Create, update, and delete collab notes, polls, and messages',
group: 'Collaboration',
},
'notifications:read': {
label: 'View notifications',
description: 'Read in-app notifications and unread counts',
group: 'Notifications',
},
'notifications:write': {
label: 'Manage notifications',
description: 'Mark notifications as read and respond to them',
group: 'Notifications',
},
'vacay:read': {
label: 'View vacation plans',
description: 'Read vacation planning data, entries, and stats',
group: 'Vacation',
},
'vacay:write': {
label: 'Manage vacation plans',
description: 'Create and manage vacation entries, holidays, and team plans',
group: 'Vacation',
},
'geo:read': {
label: 'Maps & geocoding',
description: 'Search locations, resolve map URLs, and reverse geocode coordinates',
group: 'Geo',
},
'weather:read': {
label: 'Weather forecasts',
description: 'Fetch weather forecasts for trip locations and dates',
group: 'Weather',
},
'journey:read': {
label: 'View journeys',
description: 'Read journeys, entries, and contributor list',
group: 'Journey',
},
'journey:write': {
label: 'Manage journeys',
description: 'Create, update, and delete journeys and their entries',
group: 'Journey',
},
'journey:share': {
label: 'Manage journey links',
description: 'Create, update, and revoke public share links for journeys',
group: 'Journey',
},
};
// ---------------------------------------------------------------------------
@@ -80,7 +180,7 @@ export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
/** trips:read OR trips:write OR trips:delete OR trips:share all grant read access to trips */
export function canReadTrips(scopes: string[] | null): boolean {
if (!scopes) return true;
return scopes.some(s => s === 'trips:read' || s === 'trips:write' || s === 'trips:delete' || s === 'trips:share');
return scopes.some((s) => s === 'trips:read' || s === 'trips:write' || s === 'trips:delete' || s === 'trips:share');
}
/** group:write grants write access; for trips canReadTrips handles read */
@@ -92,7 +192,7 @@ export function canWrite(scopes: string[] | null, group: string): boolean {
/** group:read OR group:write grant read access */
export function canRead(scopes: string[] | null, group: string): boolean {
if (!scopes) return true;
return scopes.some(s => s === `${group}:read` || s === `${group}:write`);
return scopes.some((s) => s === `${group}:read` || s === `${group}:write`);
}
/** trips:delete is a separate scope from trips:write */
@@ -114,6 +214,6 @@ export function canShareJourneys(scopes: string[] | null): boolean {
}
export function validateScopes(requestedScopes: string[]): { valid: boolean; invalid: string[] } {
const invalid = requestedScopes.filter(s => !ALL_SCOPES.includes(s as Scope));
const invalid = requestedScopes.filter((s) => !ALL_SCOPES.includes(s as Scope));
return { valid: invalid.length === 0, invalid };
}
+20 -4
View File
@@ -20,8 +20,16 @@ export const sessions = new Map<string, McpSession>();
export function revokeUserSessions(userId: number): void {
for (const [sid, session] of sessions) {
if (session.userId === userId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
try {
session.server.close();
} catch {
/* ignore */
}
try {
session.transport.close();
} catch {
/* ignore */
}
sessions.delete(sid);
}
}
@@ -33,8 +41,16 @@ export function revokeUserSessions(userId: number): void {
export function revokeUserSessionsForClient(userId: number, clientId: string): void {
for (const [sid, session] of sessions) {
if (session.userId === userId && session.clientId === clientId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
try {
session.server.close();
} catch {
/* ignore */
}
try {
session.transport.close();
} catch {
/* ignore */
}
sessions.delete(sid);
}
}
+19 -13
View File
@@ -1,23 +1,29 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { registerTodoTools } from './tools/todos';
import { registerAssignmentTools } from './tools/assignments';
import { registerAtlasTools } from './tools/atlas';
import { registerBudgetTools } from './tools/budget';
import { registerCollabTools } from './tools/collab';
import { registerDayTools } from './tools/days';
import { registerJourneyTools } from './tools/journey';
import { registerReservationTools } from './tools/reservations';
import { registerTagTools } from './tools/tags';
import { registerMapsWeatherTools } from './tools/mapsWeather';
import { registerNotificationTools } from './tools/notifications';
import { registerAtlasTools } from './tools/atlas';
import { registerPlaceTools } from './tools/places';
import { registerDayTools } from './tools/days';
import { registerBudgetTools } from './tools/budget';
import { registerPackingTools } from './tools/packing';
import { registerCollabTools } from './tools/collab';
import { registerTripTools } from './tools/trips';
import { registerTransportTools } from './tools/transports';
import { registerVacayTools } from './tools/vacay';
import { registerPlaceTools } from './tools/places';
import { registerMcpPrompts } from './tools/prompts';
import { registerReservationTools } from './tools/reservations';
import { registerTagTools } from './tools/tags';
import { registerTodoTools } from './tools/todos';
import { registerTransportTools } from './tools/transports';
import { registerTripTools } from './tools/trips';
import { registerVacayTools } from './tools/vacay';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
export function registerTools(
server: McpServer,
userId: number,
scopes: string[] | null,
isStaticToken = false,
getDeprecationNotice: () => string | null = () => null,
): void {
registerTripTools(server, userId, scopes, getDeprecationNotice);
registerPlaceTools(server, userId, scopes);
+185 -152
View File
@@ -1,21 +1,33 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
dayExists, placeExists, createAssignment, assignmentExistsInDay,
deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime,
dayExists,
placeExists,
createAssignment,
assignmentExistsInDay,
deleteAssignment,
reorderAssignments,
getAssignmentForTrip,
updateTime,
moveAssignment,
getParticipants as getAssignmentParticipants,
setParticipants as setAssignmentParticipants,
} from '../../services/assignmentService';
import { isDemoUser } from '../../services/authService';
import { getDay } from '../../services/dayService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import {
safeBroadcast,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerAssignmentTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
@@ -23,161 +35,182 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
// --- ASSIGNMENTS ---
if (W) server.registerTool(
'assign_place_to_day',
{
description: 'Assign a place to a specific day in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
placeId: z.number().int().positive(),
notes: z.string().max(500).optional(),
if (W)
server.registerTool(
'assign_place_to_day',
{
description: 'Assign a place to a specific day in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
placeId: z.number().int().positive(),
notes: z.string().max(500).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, dayId, placeId, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
const assignment = createAssignment(dayId, placeId, notes || null);
safeBroadcast(tripId, 'assignment:created', { assignment });
return ok({ assignment });
}
);
async ({ tripId, dayId, placeId, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayExists(dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
if (!placeExists(placeId, tripId))
return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
const assignment = createAssignment(dayId, placeId, notes || null);
safeBroadcast(tripId, 'assignment:created', { assignment });
return ok({ assignment });
},
);
if (W) server.registerTool(
'unassign_place',
{
description: 'Remove a place assignment from a day.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
if (W)
server.registerTool(
'unassign_place',
{
description: 'Remove a place assignment from a day.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, dayId, assignmentId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
deleteAssignment(assignmentId);
safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
return ok({ success: true });
}
);
async ({ tripId, dayId, assignmentId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
deleteAssignment(assignmentId);
safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
return ok({ success: true });
},
);
if (W) server.registerTool(
'update_assignment_time',
{
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
if (W)
server.registerTool(
'update_assignment_time',
{
description:
'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, assignmentId, place_time, end_time }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const existing = getAssignmentForTrip(assignmentId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const assignment = updateTime(
assignmentId,
place_time !== undefined ? place_time : (existing as any).assignment_time,
end_time !== undefined ? end_time : (existing as any).assignment_end_time
);
safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
);
async ({ tripId, assignmentId, place_time, end_time }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const existing = getAssignmentForTrip(assignmentId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const assignment = updateTime(
assignmentId,
place_time !== undefined ? place_time : (existing as any).assignment_time,
end_time !== undefined ? end_time : (existing as any).assignment_end_time,
);
safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
},
);
if (W) server.registerTool(
'move_assignment',
{
description: 'Move a place assignment to a different day.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
newDayId: z.number().int().positive(),
oldDayId: z.number().int().positive(),
orderIndex: z.number().int().min(0).optional().default(0),
if (W)
server.registerTool(
'move_assignment',
{
description: 'Move a place assignment to a different day.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
newDayId: z.number().int().positive(),
oldDayId: z.number().int().positive(),
orderIndex: z.number().int().min(0).optional().default(0),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
if (!getDay(newDayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId });
return ok({ assignment: result.assignment });
}
);
async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAssignmentForTrip(assignmentId, tripId))
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
if (!getDay(newDayId, tripId))
return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId });
return ok({ assignment: result.assignment });
},
);
if (R) server.registerTool(
'get_assignment_participants',
{
description: 'Get the list of users participating in a specific place assignment.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
if (R)
server.registerTool(
'get_assignment_participants',
{
description: 'Get the list of users participating in a specific place assignment.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, assignmentId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const participants = getAssignmentParticipants(assignmentId);
return ok({ participants });
}
);
async ({ tripId, assignmentId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAssignmentForTrip(assignmentId, tripId))
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const participants = getAssignmentParticipants(assignmentId);
return ok({ participants });
},
);
if (W) server.registerTool(
'set_assignment_participants',
{
description: 'Set the participants for a place assignment (replaces current list).',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
userIds: z.array(z.number().int().positive()).describe('User IDs to set as participants; empty array clears all'),
if (W)
server.registerTool(
'set_assignment_participants',
{
description: 'Set the participants for a place assignment (replaces current list).',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
userIds: z
.array(z.number().int().positive())
.describe('User IDs to set as participants; empty array clears all'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, assignmentId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const participants = setAssignmentParticipants(assignmentId, userIds);
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
return ok({ participants });
}
);
async ({ tripId, assignmentId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAssignmentForTrip(assignmentId, tripId))
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const participants = setAssignmentParticipants(assignmentId, userIds);
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
return ok({ participants });
},
);
// --- REORDER ---
if (W) server.registerTool(
'reorder_day_assignments',
{
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
if (W)
server.registerTool(
'reorder_day_assignments',
{
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
assignmentIds: z
.array(z.number().int().positive())
.min(1)
.max(200)
.describe('Assignment IDs in desired display order'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, dayId, assignmentIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
reorderAssignments(dayId, assignmentIds);
safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
return ok({ success: true, dayId, order: assignmentIds });
}
);
async ({ tripId, dayId, assignmentIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getDay(dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
reorderAssignments(dayId, assignmentIds);
safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
return ok({ success: true, dayId, order: assignmentIds });
},
);
}
+114 -88
View File
@@ -1,19 +1,30 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import {
markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem,
getStats as getAtlasStats, listManuallyVisitedRegions,
markRegionVisited, unmarkRegionVisited, getCountryPlaces, updateBucketItem,
} from '../../services/atlasService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { isAddonEnabled } from '../../services/adminService';
import {
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
demoDenied, ok,
} from './_shared';
markCountryVisited,
unmarkCountryVisited,
createBucketItem,
deleteBucketItem,
getStats as getAtlasStats,
listManuallyVisitedRegions,
markRegionVisited,
unmarkRegionVisited,
getCountryPlaces,
updateBucketItem,
} from '../../services/atlasService';
import { isDemoUser } from '../../services/authService';
import { canRead, canWrite } from '../scopes';
import {
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
demoDenied,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerAtlasTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'atlas');
@@ -23,80 +34,90 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
// --- BUCKET LIST ---
if (W) server.registerTool(
'create_bucket_list_item',
{
description: 'Add a destination to your personal travel bucket list.',
inputSchema: {
name: z.string().min(1).max(200).describe('Destination or experience name'),
lat: z.number().optional(),
lng: z.number().optional(),
country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'),
notes: z.string().max(1000).optional(),
if (W)
server.registerTool(
'create_bucket_list_item',
{
description: 'Add a destination to your personal travel bucket list.',
inputSchema: {
name: z.string().min(1).max(200).describe('Destination or experience name'),
lat: z.number().optional(),
lng: z.number().optional(),
country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ name, lat, lng, country_code, notes }) => {
if (isDemoUser(userId)) return demoDenied();
const item = createBucketItem(userId, { name, lat, lng, country_code, notes });
return ok({ item });
}
);
async ({ name, lat, lng, country_code, notes }) => {
if (isDemoUser(userId)) return demoDenied();
const item = createBucketItem(userId, { name, lat, lng, country_code, notes });
return ok({ item });
},
);
if (W) server.registerTool(
'delete_bucket_list_item',
{
description: 'Remove an item from your travel bucket list.',
inputSchema: {
itemId: z.number().int().positive(),
if (W)
server.registerTool(
'delete_bucket_list_item',
{
description: 'Remove an item from your travel bucket list.',
inputSchema: {
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ itemId }) => {
if (isDemoUser(userId)) return demoDenied();
const deleted = deleteBucketItem(userId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
return ok({ success: true });
}
);
async ({ itemId }) => {
if (isDemoUser(userId)) return demoDenied();
const deleted = deleteBucketItem(userId, itemId);
if (!deleted)
return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
return ok({ success: true });
},
);
// --- ATLAS ---
if (W) server.registerTool(
'mark_country_visited',
{
description: 'Mark a country as visited in your Atlas.',
inputSchema: {
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'),
if (W)
server.registerTool(
'mark_country_visited',
{
description: 'Mark a country as visited in your Atlas.',
inputSchema: {
country_code: z
.string()
.length(2)
.toUpperCase()
.describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied();
markCountryVisited(userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
);
async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied();
markCountryVisited(userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
},
);
if (W) server.registerTool(
'unmark_country_visited',
{
description: 'Remove a country from your visited countries in Atlas.',
inputSchema: {
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'),
if (W)
server.registerTool(
'unmark_country_visited',
{
description: 'Remove a country from your visited countries in Atlas.',
inputSchema: {
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied();
unmarkCountryVisited(userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
);
async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied();
unmarkCountryVisited(userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
},
);
// --- ATLAS EXPANDED ---
if (R) server.registerTool(
if (R)
server.registerTool(
'get_atlas_stats',
{
description: 'Get atlas statistics — total visited countries, region counts, continent breakdown.',
@@ -106,10 +127,11 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
async () => {
const stats = await getAtlasStats(userId);
return ok({ stats });
}
},
);
if (R) server.registerTool(
if (R)
server.registerTool(
'list_visited_regions',
{
description: 'List all manually visited sub-country regions for the current user.',
@@ -119,10 +141,11 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
async () => {
const regions = listManuallyVisitedRegions(userId);
return ok({ regions });
}
},
);
if (W) server.registerTool(
if (W)
server.registerTool(
'mark_region_visited',
{
description: 'Mark a sub-country region as visited.',
@@ -136,12 +159,13 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
async ({ regionCode, regionName, countryCode }) => {
if (isDemoUser(userId)) return demoDenied();
markRegionVisited(userId, regionCode, regionName, countryCode);
const region = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
const region = listManuallyVisitedRegions(userId).find((r) => r.region_code === regionCode);
return ok({ region });
}
},
);
if (W) server.registerTool(
if (W)
server.registerTool(
'unmark_region_visited',
{
description: 'Remove a region from the visited list.',
@@ -154,13 +178,14 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
if (isDemoUser(userId)) return demoDenied();
unmarkRegionVisited(userId, regionCode);
return ok({ success: true });
}
},
);
if (R) server.registerTool(
if (R)
server.registerTool(
'get_country_atlas_places',
{
description: 'Get places saved in the user\'s atlas for a specific country.',
description: "Get places saved in the user's atlas for a specific country.",
inputSchema: {
countryCode: z.string().describe('ISO 3166-1 alpha-2 country code'),
},
@@ -169,10 +194,11 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
async ({ countryCode }) => {
const result = getCountryPlaces(userId, countryCode);
return ok(result);
}
},
);
if (W) server.registerTool(
if (W)
server.registerTool(
'update_bucket_list_item',
{
description: 'Update a bucket list item (notes, name, target date, location).',
@@ -192,6 +218,6 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
const item = updateBucketItem(userId, itemId, { name, notes, lat, lng, country_code, target_date });
if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
return ok({ item });
}
},
);
}
+174 -155
View File
@@ -1,174 +1,193 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { canAccessTrip, db } from '../../db/database';
import { isAddonEnabled } from '../../services/adminService';
import { isDemoUser } from '../../services/authService';
import {
createBudgetItem, updateBudgetItem, deleteBudgetItem,
createBudgetItem,
updateBudgetItem,
deleteBudgetItem,
updateMembers as updateBudgetMembers,
toggleMemberPaid,
} from '../../services/budgetService';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
const W = canWrite(scopes, 'budget');
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
// --- BUDGET ---
// --- BUDGET ---
if (W) server.registerTool(
'create_budget_item',
{
description: 'Add a budget/expense item to a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
total_price: z.number().nonnegative(),
note: z.string().max(500).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, total_price, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createBudgetItem(tripId, { category, name, total_price, note });
safeBroadcast(tripId, 'budget:created', { item });
return ok({ item });
}
);
if (W) server.registerTool(
'delete_budget_item',
{
description: 'Delete a budget item from a trip.',
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();
const deleted = deleteBudgetItem(itemId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:deleted', { itemId });
return ok({ success: true });
}
);
// --- BUDGET (update) ---
if (W) server.registerTool(
'update_budget_item',
{
description: 'Update an existing budget/expense item in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
total_price: z.number().nonnegative().optional(),
persons: z.number().int().positive().nullable().optional(),
days: z.number().int().positive().nullable().optional(),
note: z.string().max(500).nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:updated', { item });
return ok({ item });
}
);
// --- BUDGET ADVANCED ---
if (W) server.registerTool(
'create_budget_item_with_members',
{
description: 'Create a budget/expense item and optionally set the trip members splitting it in one atomic operation. If userIds is omitted or empty, behaves like create_budget_item. Only use when the place does not yet exist — if it already exists, use set_budget_item_members directly.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
total_price: z.number().nonnegative(),
note: z.string().max(500).optional(),
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit or pass empty array to skip member assignment'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, total_price, note, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const hasMembers = userIds && userIds.length > 0;
try {
const run = db.transaction(() => {
if (W)
server.registerTool(
'create_budget_item',
{
description: 'Add a budget/expense item to a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
total_price: z.number().nonnegative(),
note: z.string().max(500).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, total_price, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createBudgetItem(tripId, { category, name, total_price, note });
if (hasMembers) {
return updateBudgetMembers(item.id, tripId, userIds!);
safeBroadcast(tripId, 'budget:created', { item });
return ok({ item });
},
);
if (W)
server.registerTool(
'delete_budget_item',
{
description: 'Delete a budget item from a trip.',
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();
const deleted = deleteBudgetItem(itemId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:deleted', { itemId });
return ok({ success: true });
},
);
// --- BUDGET (update) ---
if (W)
server.registerTool(
'update_budget_item',
{
description: 'Update an existing budget/expense item in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
total_price: z.number().nonnegative().optional(),
persons: z.number().int().positive().nullable().optional(),
days: z.number().int().positive().nullable().optional(),
note: z.string().max(500).nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:updated', { item });
return ok({ item });
},
);
// --- BUDGET ADVANCED ---
if (W)
server.registerTool(
'create_budget_item_with_members',
{
description:
'Create a budget/expense item and optionally set the trip members splitting it in one atomic operation. If userIds is omitted or empty, behaves like create_budget_item. Only use when the place does not yet exist — if it already exists, use set_budget_item_members directly.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
total_price: z.number().nonnegative(),
note: z.string().max(500).optional(),
userIds: z
.array(z.number().int().positive())
.optional()
.describe('User IDs splitting this item; omit or pass empty array to skip member assignment'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, total_price, note, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const hasMembers = userIds && userIds.length > 0;
try {
const run = db.transaction(() => {
const item = createBudgetItem(tripId, { category, name, total_price, note });
if (hasMembers) {
return updateBudgetMembers(item.id, tripId, userIds);
}
return { item };
});
const result = run();
safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result });
if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result });
return ok({ item: result });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
}
return { item };
});
const result = run();
safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result });
if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result });
return ok({ item: result });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
}
}
);
},
);
if (W) server.registerTool(
'set_budget_item_members',
{
description: 'Set which trip members are splitting a budget item (replaces current member list).',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
userIds: z.array(z.number().int().positive()).describe('User IDs splitting this item; empty array clears all'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updateBudgetMembers(itemId, tripId, userIds);
safeBroadcast(tripId, 'budget:members-updated', { item });
return ok({ item });
}
);
if (W)
server.registerTool(
'set_budget_item_members',
{
description: 'Set which trip members are splitting a budget item (replaces current member list).',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
userIds: z
.array(z.number().int().positive())
.describe('User IDs splitting this item; empty array clears all'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updateBudgetMembers(itemId, tripId, userIds);
safeBroadcast(tripId, 'budget:members-updated', { item });
return ok({ item });
},
);
if (W) server.registerTool(
'toggle_budget_member_paid',
{
description: 'Mark or unmark a member as having paid their share of a budget item.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
memberId: z.number().int().positive().describe('User ID of the member'),
paid: z.boolean(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, memberId, paid }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const member = toggleMemberPaid(itemId, memberId, paid);
safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, member });
return ok({ member });
}
);
if (W)
server.registerTool(
'toggle_budget_member_paid',
{
description: 'Mark or unmark a member as having paid their share of a budget item.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
memberId: z.number().int().positive().describe('User ID of the member'),
paid: z.boolean(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, memberId, paid }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const member = toggleMemberPaid(itemId, memberId, paid);
safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, member });
return ok({ member });
},
);
} // isAddonEnabled(BUDGET)
}
+128 -93
View File
@@ -1,20 +1,35 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { canAccessTrip } from '../../db/database';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { isDemoUser } from '../../services/authService';
import {
createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote,
listPolls, createPoll, votePoll, closePoll, deletePoll,
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
createNote as createCollabNote,
updateNote as updateCollabNote,
deleteNote as deleteCollabNote,
listPolls,
createPoll,
votePoll,
closePoll,
deletePoll,
listMessages,
createMessage,
deleteMessage,
addOrRemoveReaction,
} from '../../services/collabService';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import {
safeBroadcast,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerCollabTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'collab');
@@ -26,78 +41,90 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
// --- COLLAB NOTES ---
if (features.notes && W) server.registerTool(
'create_collab_note',
{
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
content: z.string().max(10000).optional(),
category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().default(false).describe('Pin the note to the top'),
if (features.notes && W)
server.registerTool(
'create_collab_note',
{
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
content: z.string().max(10000).optional(),
category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional()
.describe('Hex color for the note card'),
pinned: z.boolean().optional().default(false).describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = createCollabNote(tripId, userId, { title, content, category, color, pinned });
safeBroadcast(tripId, 'collab:note:created', { note });
return ok({ note });
}
);
async ({ tripId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = createCollabNote(tripId, userId, { title, content, category, color, pinned });
safeBroadcast(tripId, 'collab:note:created', { note });
return ok({ note });
},
);
if (features.notes && W) server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
content: z.string().max(10000).optional(),
category: z.string().max(100).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().describe('Pin the note to the top'),
if (features.notes && W)
server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
content: z.string().max(10000).optional(),
category: z.string().max(100).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional()
.describe('Hex color for the note card'),
pinned: z.boolean().optional().describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, noteId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:updated', { note });
return ok({ note });
}
);
async ({ tripId, noteId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:updated', { note });
return ok({ note });
},
);
if (features.notes && W) server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
if (features.notes && W)
server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteCollabNote(tripId, noteId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:deleted', { noteId });
return ok({ success: true });
}
);
async ({ tripId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteCollabNote(tripId, noteId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:deleted', { noteId });
return ok({ success: true });
},
);
// --- COLLAB POLLS & CHAT ---
if (features.polls && R) server.registerTool(
'list_collab_polls',
if (features.polls && R)
server.registerTool(
'list_collab_polls',
{
description: 'List all polls for a trip.',
inputSchema: {
@@ -109,10 +136,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (!canAccessTrip(tripId, userId)) return noAccess();
const polls = listPolls(tripId);
return ok({ polls });
}
},
);
if (features.polls && W) server.registerTool(
if (features.polls && W)
server.registerTool(
'create_collab_poll',
{
description: 'Create a new poll in the collab panel.',
@@ -131,10 +159,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
const poll = createPoll(tripId, userId, { question, options, multiple, deadline });
safeBroadcast(tripId, 'collab:poll:created', { poll });
return ok({ poll });
}
},
);
if (features.polls && W) server.registerTool(
if (features.polls && W)
server.registerTool(
'vote_collab_poll',
{
description: 'Vote on a poll option (or remove vote if already voted for that option).',
@@ -151,10 +180,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll });
return ok({ poll: result.poll });
}
},
);
if (features.polls && W) server.registerTool(
if (features.polls && W)
server.registerTool(
'close_collab_poll',
{
description: 'Close a poll so no more votes can be cast.',
@@ -171,10 +201,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (!poll) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
safeBroadcast(tripId, 'collab:poll:closed', { poll });
return ok({ poll });
}
},
);
if (features.polls && W) server.registerTool(
if (features.polls && W)
server.registerTool(
'delete_collab_poll',
{
description: 'Delete a poll and all its votes.',
@@ -191,10 +222,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (!deleted) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
safeBroadcast(tripId, 'collab:poll:deleted', { pollId });
return ok({ success: true });
}
},
);
if (features.chat && R) server.registerTool(
if (features.chat && R)
server.registerTool(
'list_collab_messages',
{
description: 'List chat messages for a trip (most recent 100, oldest-first).',
@@ -208,10 +240,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (!canAccessTrip(tripId, userId)) return noAccess();
const messages = listMessages(tripId, before);
return ok({ messages });
}
},
);
if (features.chat && W) server.registerTool(
if (features.chat && W)
server.registerTool(
'send_collab_message',
{
description: "Send a chat message to a trip's collab channel.",
@@ -229,10 +262,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:message:created', { message: result.message });
return ok({ message: result.message });
}
},
);
if (features.chat && W) server.registerTool(
if (features.chat && W)
server.registerTool(
'delete_collab_message',
{
description: 'Delete a chat message (only the message owner can delete their own messages).',
@@ -249,10 +283,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username });
return ok({ success: true });
}
},
);
if (features.chat && W) server.registerTool(
if (features.chat && W)
server.registerTool(
'react_collab_message',
{
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
@@ -270,6 +305,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (!result.found) return { content: [{ type: 'text' as const, text: 'Message not found.' }], isError: true };
safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions });
return ok({ reactions: result.reactions });
}
},
);
}
+148 -40
View File
@@ -1,23 +1,37 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip, db } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
getDay, updateDay, validateAccommodationRefs,
createDay, deleteDay,
createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation,
} from '../../services/dayService';
import { createPlace } from '../../services/placeService';
import {
createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote,
deleteNote as deleteDayNote, dayExists as dayNoteExists,
createNote as createDayNote,
getNote as getDayNote,
updateNote as updateDayNote,
deleteNote as deleteDayNote,
dayExists as dayNoteExists,
} from '../../services/dayNoteService';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
getDay,
updateDay,
validateAccommodationRefs,
createDay,
deleteDay,
createAccommodation,
getAccommodation,
updateAccommodation,
deleteAccommodation,
} from '../../services/dayService';
import { createPlace } from '../../services/placeService';
import { canWrite } from '../scopes';
import {
safeBroadcast,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerDayTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'trips')) return;
@@ -43,7 +57,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
const updated = updateDay(dayId, current, title !== undefined ? { title } : {});
safeBroadcast(tripId, 'day:updated', { day: updated });
return ok({ day: updated });
}
},
);
server.registerTool(
@@ -63,7 +77,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
const day = createDay(tripId, date, notes);
safeBroadcast(tripId, 'day:created', { day });
return ok({ day });
}
},
);
server.registerTool(
@@ -79,11 +93,12 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, dayId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
if (!getDay(dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
deleteDay(dayId);
safeBroadcast(tripId, 'day:deleted', { id: dayId });
return ok({ success: true });
}
},
);
server.registerTool(
@@ -106,17 +121,27 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
if (errors.length > 0)
return { content: [{ type: 'text' as const, text: errors.map((e) => e.message).join(', ') }], isError: true };
const accommodation = createAccommodation(tripId, {
place_id,
start_day_id,
end_day_id,
check_in,
check_out,
confirmation,
notes,
});
safeBroadcast(tripId, 'accommodation:created', { accommodation });
return ok({ accommodation });
}
},
);
server.registerTool(
'create_place_accommodation',
{
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
description:
'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
@@ -124,8 +149,16 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
category_id: z
.number()
.int()
.positive()
.optional()
.describe('Category ID — use list_categories to see available options'),
google_place_id: z
.string()
.optional()
.describe('Google Place ID from search_place — enables opening hours display'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(),
@@ -141,15 +174,62 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
async ({
tripId,
name,
description,
lat,
lng,
address,
category_id,
google_place_id,
osm_id,
place_notes,
website,
phone,
start_day_id,
end_day_id,
check_in,
check_out,
confirmation,
accommodation_notes,
price,
currency,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
if (dayErrors.length > 0)
return {
content: [{ type: 'text' as const, text: dayErrors.map((e) => e.message).join(', ') }],
isError: true,
};
try {
const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
const place = createPlace(String(tripId), {
name,
description,
lat,
lng,
address,
category_id,
google_place_id,
osm_id,
notes: place_notes,
website,
phone,
price,
currency,
});
const accommodation = createAccommodation(tripId, {
place_id: place.id,
start_day_id,
end_day_id,
check_in,
check_out,
confirmation,
notes: accommodation_notes,
});
return { place, accommodation };
});
const result = run();
@@ -157,9 +237,12 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation });
return ok(result);
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }],
isError: true,
};
}
}
},
);
server.registerTool(
@@ -179,15 +262,33 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
async ({
tripId,
accommodationId,
place_id,
start_day_id,
end_day_id,
check_in,
check_out,
confirmation,
notes,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const existing = getAccommodation(accommodationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
const accommodation = updateAccommodation(accommodationId, existing, {
place_id,
start_day_id,
end_day_id,
check_in,
check_out,
confirmation,
notes,
});
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
return ok({ accommodation });
}
},
);
server.registerTool(
@@ -203,11 +304,12 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, accommodationId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
if (!getAccommodation(accommodationId, tripId))
return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const { linkedReservationId } = deleteAccommodation(accommodationId);
safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId });
return ok({ success: true, linkedReservationId });
}
},
);
// --- DAY NOTES ---
@@ -228,11 +330,12 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, dayId, text, time, icon }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
if (!dayNoteExists(dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const note = createDayNote(dayId, tripId, text, time, icon);
safeBroadcast(tripId, 'dayNote:created', { dayId, note });
return ok({ note });
}
},
);
server.registerTool(
@@ -244,7 +347,12 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
dayId: z.number().int().positive(),
noteId: z.number().int().positive(),
text: z.string().min(1).max(500).optional(),
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
time: z
.string()
.max(150)
.nullable()
.optional()
.describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
@@ -257,7 +365,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
safeBroadcast(tripId, 'dayNote:updated', { dayId, note });
return ok({ note });
}
},
);
server.registerTool(
@@ -279,6 +387,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
deleteDayNote(noteId);
safeBroadcast(tripId, 'dayNote:deleted', { noteId, dayId });
return ok({ success: true });
}
},
);
}
+414 -357
View File
@@ -1,24 +1,44 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { isAddonEnabled } from '../../services/adminService';
import { isDemoUser } from '../../services/authService';
import {
addContributor, addTripToJourney, canAccessJourney, createEntry, createJourney,
deleteEntry, deleteJourney, getJourneyFull, getSuggestions, listEntries,
listJourneys, listUserTrips, removeContributor, removeTripFromJourney,
reorderEntries, updateContributorRole, updateEntry, updateJourney,
addContributor,
addTripToJourney,
canAccessJourney,
createEntry,
createJourney,
deleteEntry,
deleteJourney,
getJourneyFull,
getSuggestions,
listEntries,
listJourneys,
listUserTrips,
removeContributor,
removeTripFromJourney,
reorderEntries,
updateContributorRole,
updateEntry,
updateJourney,
updateJourneyPreferences,
} from '../../services/journeyService';
import {
createOrUpdateJourneyShareLink, deleteJourneyShareLink, getJourneyShareLink,
createOrUpdateJourneyShareLink,
deleteJourneyShareLink,
getJourneyShareLink,
} from '../../services/journeyShareService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
demoDenied, ok,
} from './_shared';
import { canRead, canShareJourneys, canWrite } from '../scopes';
import {
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
demoDenied,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
function notFound(msg: string) {
return { content: [{ type: 'text' as const, text: msg }], isError: true };
@@ -33,389 +53,426 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
// --- READ TOOLS ---
if (R) server.registerTool(
'list_journeys',
{
description: 'List all journeys owned or contributed to by the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const journeys = listJourneys(userId);
return ok({ journeys });
}
);
if (R) server.registerTool(
'get_journey',
{
description: 'Get a full journey including entries, contributors, and linked trips.',
inputSchema: {
journeyId: z.number().int().positive(),
if (R)
server.registerTool(
'list_journeys',
{
description: 'List all journeys owned or contributed to by the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
const journey = getJourneyFull(journeyId, userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
);
if (R) server.registerTool(
'list_journey_entries',
{
description: 'List all entries in a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
async () => {
const journeys = listJourneys(userId);
return ok({ journeys });
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const entries = listEntries(journeyId, userId);
return ok({ entries });
}
);
);
if (R) server.registerTool(
'list_journey_contributors',
{
description: 'List all contributors (owner and collaborators) of a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
if (R)
server.registerTool(
'get_journey',
{
description: 'Get a full journey including entries, contributors, and linked trips.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
const journey = getJourneyFull(journeyId, userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ contributors: (journey as any).contributors ?? [] });
}
);
async ({ journeyId }) => {
const journey = getJourneyFull(journeyId, userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
},
);
if (R) server.registerTool(
'get_journey_suggestions',
{
description: 'Get trip suggestions for creating a new journey (recently completed trips not yet in any journey).',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const trips = getSuggestions(userId);
return ok({ trips });
}
);
if (R)
server.registerTool(
'list_journey_entries',
{
description: 'List all entries in a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const entries = listEntries(journeyId, userId);
return ok({ entries });
},
);
if (R) server.registerTool(
'list_journey_available_trips',
{
description: 'List all trips available to link to a journey.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const trips = listUserTrips(userId);
return ok({ trips });
}
);
if (R)
server.registerTool(
'list_journey_contributors',
{
description: 'List all contributors (owner and collaborators) of a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
const journey = getJourneyFull(journeyId, userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ contributors: (journey as any).contributors ?? [] });
},
);
if (R)
server.registerTool(
'get_journey_suggestions',
{
description:
'Get trip suggestions for creating a new journey (recently completed trips not yet in any journey).',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const trips = getSuggestions(userId);
return ok({ trips });
},
);
if (R)
server.registerTool(
'list_journey_available_trips',
{
description: 'List all trips available to link to a journey.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const trips = listUserTrips(userId);
return ok({ trips });
},
);
// --- WRITE TOOLS ---
if (W) server.registerTool(
'create_journey',
{
description: 'Create a new journey, optionally linking existing trips.',
inputSchema: {
title: z.string().min(1).max(200),
subtitle: z.string().max(300).optional(),
trip_ids: z.array(z.number().int().positive()).optional(),
if (W)
server.registerTool(
'create_journey',
{
description: 'Create a new journey, optionally linking existing trips.',
inputSchema: {
title: z.string().min(1).max(200),
subtitle: z.string().max(300).optional(),
trip_ids: z.array(z.number().int().positive()).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ title, subtitle, trip_ids }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = createJourney(userId, { title, subtitle, trip_ids });
return ok({ journey });
}
);
async ({ title, subtitle, trip_ids }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = createJourney(userId, { title, subtitle, trip_ids });
return ok({ journey });
},
);
if (W) server.registerTool(
'update_journey',
{
description: 'Update an existing journey\'s title, subtitle, cover, or status. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
subtitle: z.string().max(300).optional(),
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
if (W)
server.registerTool(
'update_journey',
{
description: "Update an existing journey's title, subtitle, cover, or status. Owner only.",
inputSchema: {
journeyId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
subtitle: z.string().max(300).optional(),
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, title, subtitle, status }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = updateJourney(journeyId, userId, { title, subtitle, status });
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
);
async ({ journeyId, title, subtitle, status }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = updateJourney(journeyId, userId, { title, subtitle, status });
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
},
);
if (W) server.registerTool(
'delete_journey',
{
description: 'Delete a journey. Owner only — this cannot be undone.',
inputSchema: {
journeyId: z.number().int().positive(),
if (W)
server.registerTool(
'delete_journey',
{
description: 'Delete a journey. Owner only — this cannot be undone.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteJourney(journeyId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteJourney(journeyId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
},
);
if (W) server.registerTool(
'add_journey_trip',
{
description: 'Link a trip to a journey. Syncs skeleton entries for all places in the trip.',
inputSchema: {
journeyId: z.number().int().positive(),
tripId: z.number().int().positive(),
if (W)
server.registerTool(
'add_journey_trip',
{
description: 'Link a trip to a journey. Syncs skeleton entries for all places in the trip.',
inputSchema: {
journeyId: z.number().int().positive(),
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ journeyId, tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const success = addTripToJourney(journeyId, tripId, userId);
return ok({ success });
}
);
async ({ journeyId, tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const success = addTripToJourney(journeyId, tripId, userId);
return ok({ success });
},
);
if (W) server.registerTool(
'remove_journey_trip',
{
description: 'Unlink a trip from a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
tripId: z.number().int().positive(),
if (W)
server.registerTool(
'remove_journey_trip',
{
description: 'Unlink a trip from a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId, tripId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = removeTripFromJourney(journeyId, tripId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success });
}
);
async ({ journeyId, tripId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = removeTripFromJourney(journeyId, tripId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success });
},
);
if (W) server.registerTool(
'create_journey_entry',
{
description: 'Create a new entry in a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Entry date (YYYY-MM-DD)'),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_time: z.string().optional().describe('Time of day (e.g. "14:30")'),
location_name: z.string().optional(),
mood: z.string().optional(),
sort_order: z.number().int().min(0).optional(),
if (W)
server.registerTool(
'create_journey_entry',
{
description: 'Create a new entry in a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
entry_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('Entry date (YYYY-MM-DD)'),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_time: z.string().optional().describe('Time of day (e.g. "14:30")'),
location_name: z.string().optional(),
mood: z.string().optional(),
sort_order: z.number().int().min(0).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ journeyId, entry_date, title, story, entry_time, location_name, mood, sort_order }) => {
if (isDemoUser(userId)) return demoDenied();
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
if (!entry) return notFound('Journey not found or access denied.');
return ok({ entry });
}
);
async ({ journeyId, entry_date, title, story, entry_time, location_name, mood, sort_order }) => {
if (isDemoUser(userId)) return demoDenied();
const entry = createEntry(journeyId, userId, {
entry_date,
title,
story,
entry_time,
location_name,
mood,
sort_order,
});
if (!entry) return notFound('Journey not found or access denied.');
return ok({ entry });
},
);
if (W) server.registerTool(
'update_journey_entry',
{
description: 'Update an existing journey entry.',
inputSchema: {
entryId: z.number().int().positive(),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
entry_time: z.string().optional(),
mood: z.string().optional(),
if (W)
server.registerTool(
'update_journey_entry',
{
description: 'Update an existing journey entry.',
inputSchema: {
entryId: z.number().int().positive(),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
entry_time: z.string().optional(),
mood: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ entryId, title, story, entry_date, entry_time, mood }) => {
if (isDemoUser(userId)) return demoDenied();
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
if (!entry) return notFound('Entry not found or access denied.');
return ok({ entry });
}
);
async ({ entryId, title, story, entry_date, entry_time, mood }) => {
if (isDemoUser(userId)) return demoDenied();
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
if (!entry) return notFound('Entry not found or access denied.');
return ok({ entry });
},
);
if (W) server.registerTool(
'delete_journey_entry',
{
description: 'Delete a journey entry.',
inputSchema: {
entryId: z.number().int().positive(),
if (W)
server.registerTool(
'delete_journey_entry',
{
description: 'Delete a journey entry.',
inputSchema: {
entryId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ entryId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteEntry(entryId, userId, undefined);
if (!success) return notFound('Entry not found or access denied.');
return ok({ success: true });
}
);
async ({ entryId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteEntry(entryId, userId, undefined);
if (!success) return notFound('Entry not found or access denied.');
return ok({ success: true });
},
);
if (W) server.registerTool(
'reorder_journey_entries',
{
description: 'Reorder entries within a journey by providing the desired order of entry IDs.',
inputSchema: {
journeyId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()),
if (W)
server.registerTool(
'reorder_journey_entries',
{
description: 'Reorder entries within a journey by providing the desired order of entry IDs.',
inputSchema: {
journeyId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
const success = reorderEntries(journeyId, userId, orderedIds, undefined);
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
return ok({ success: true });
}
);
async ({ journeyId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
const success = reorderEntries(journeyId, userId, orderedIds, undefined);
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
return ok({ success: true });
},
);
if (W) server.registerTool(
'add_journey_contributor',
{
description: 'Add a contributor to a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
if (W)
server.registerTool(
'add_journey_contributor',
{
description: 'Add a contributor to a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ journeyId, targetUserId, role }) => {
if (isDemoUser(userId)) return demoDenied();
const success = addContributor(journeyId, userId, targetUserId, role);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
async ({ journeyId, targetUserId, role }) => {
if (isDemoUser(userId)) return demoDenied();
const success = addContributor(journeyId, userId, targetUserId, role);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
},
);
if (W) server.registerTool(
'update_journey_contributor_role',
{
description: 'Update the role of a journey contributor. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
if (W)
server.registerTool(
'update_journey_contributor_role',
{
description: 'Update the role of a journey contributor. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, targetUserId, role }) => {
if (isDemoUser(userId)) return demoDenied();
const success = updateContributorRole(journeyId, userId, targetUserId, role);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
async ({ journeyId, targetUserId, role }) => {
if (isDemoUser(userId)) return demoDenied();
const success = updateContributorRole(journeyId, userId, targetUserId, role);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
},
);
if (W) server.registerTool(
'remove_journey_contributor',
{
description: 'Remove a contributor from a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
if (W)
server.registerTool(
'remove_journey_contributor',
{
description: 'Remove a contributor from a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId, targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = removeContributor(journeyId, userId, targetUserId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
async ({ journeyId, targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = removeContributor(journeyId, userId, targetUserId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
},
);
if (W) server.registerTool(
'update_journey_preferences',
{
description: 'Update per-user preferences for a journey (e.g. hide skeleton entries).',
inputSchema: {
journeyId: z.number().int().positive(),
hide_skeletons: z.boolean().optional(),
if (W)
server.registerTool(
'update_journey_preferences',
{
description: 'Update per-user preferences for a journey (e.g. hide skeleton entries).',
inputSchema: {
journeyId: z.number().int().positive(),
hide_skeletons: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, hide_skeletons }) => {
if (isDemoUser(userId)) return demoDenied();
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
async ({ journeyId, hide_skeletons }) => {
if (isDemoUser(userId)) return demoDenied();
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
return ok({ success: true });
},
);
// --- SHARE TOOLS ---
if (S) server.registerTool(
'get_journey_share_link',
{
description: 'Get the current public share link for a journey. Returns null if none exists.',
inputSchema: {
journeyId: z.number().int().positive(),
if (S)
server.registerTool(
'get_journey_share_link',
{
description: 'Get the current public share link for a journey. Returns null if none exists.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
const shareLink = getJourneyShareLink(journeyId);
return ok({ shareLink });
}
);
async ({ journeyId }) => {
const shareLink = getJourneyShareLink(journeyId);
return ok({ shareLink });
},
);
if (S) server.registerTool(
'create_journey_share_link',
{
description: 'Create or update the public share link for a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
if (S)
server.registerTool(
'create_journey_share_link',
{
description: 'Create or update the public share link for a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const shareLink = createOrUpdateJourneyShareLink(journeyId, userId, {});
if (!shareLink) return notFound('Journey not found or access denied.');
return ok({ shareLink });
}
);
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const shareLink = createOrUpdateJourneyShareLink(journeyId, userId, {});
if (!shareLink) return notFound('Journey not found or access denied.');
return ok({ shareLink });
},
);
if (S) server.registerTool(
'delete_journey_share_link',
{
description: 'Revoke the public share link for a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
if (S)
server.registerTool(
'delete_journey_share_link',
{
description: 'Revoke the public share link for a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteJourneyShareLink(journeyId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteJourneyShareLink(journeyId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
},
);
}
+148 -120
View File
@@ -1,148 +1,176 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { findByIata, searchAirports } from '../../services/airportService';
import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService';
import { getWeather, getDetailedWeather } from '../../services/weatherService';
import {
TOOL_ANNOTATIONS_READONLY,
ok,
} from './_shared';
import { canRead } from '../scopes';
import { TOOL_ANNOTATIONS_READONLY, ok } from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerMapsWeatherTools(server: McpServer, userId: number, scopes: string[] | null): void {
const canGeo = canRead(scopes, 'geo');
const canGeo = canRead(scopes, 'geo');
const canWeather = canRead(scopes, 'weather');
// --- MAPS EXTRAS ---
if (canGeo) server.registerTool(
'get_place_details',
{
description: 'Fetch detailed information about a place by its Google Place ID.',
inputSchema: {
placeId: z.string().describe('Google Place ID'),
lang: z.string().optional().default('en'),
if (canGeo)
server.registerTool(
'get_place_details',
{
description: 'Fetch detailed information about a place by its Google Place ID.',
inputSchema: {
placeId: z.string().describe('Google Place ID'),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ placeId, lang }) => {
const details = await getPlaceDetails(userId, placeId, lang ?? 'en');
if (!details) return { content: [{ type: 'text' as const, text: 'Place not found or maps service not configured.' }], isError: true };
return ok({ details });
}
);
async ({ placeId, lang }) => {
const details = await getPlaceDetails(userId, placeId, lang ?? 'en');
if (!details)
return {
content: [{ type: 'text' as const, text: 'Place not found or maps service not configured.' }],
isError: true,
};
return ok({ details });
},
);
if (canGeo) server.registerTool(
'reverse_geocode',
{
description: 'Get a human-readable address for given coordinates.',
inputSchema: {
lat: z.number(),
lng: z.number(),
lang: z.string().optional().default('en'),
if (canGeo)
server.registerTool(
'reverse_geocode',
{
description: 'Get a human-readable address for given coordinates.',
inputSchema: {
lat: z.number(),
lng: z.number(),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ lat, lng, lang }) => {
const result = await reverseGeocode(String(lat), String(lng), lang ?? 'en');
if (!result) return { content: [{ type: 'text' as const, text: 'Reverse geocode failed or maps service not configured.' }], isError: true };
return ok(result);
}
);
async ({ lat, lng, lang }) => {
const result = await reverseGeocode(String(lat), String(lng), lang ?? 'en');
if (!result)
return {
content: [{ type: 'text' as const, text: 'Reverse geocode failed or maps service not configured.' }],
isError: true,
};
return ok(result);
},
);
if (canGeo) server.registerTool(
'resolve_maps_url',
{
description: 'Resolve a Google Maps share URL to coordinates and place name.',
inputSchema: {
url: z.string().describe('Google Maps share URL'),
if (canGeo)
server.registerTool(
'resolve_maps_url',
{
description: 'Resolve a Google Maps share URL to coordinates and place name.',
inputSchema: {
url: z.string().describe('Google Maps share URL'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ url }) => {
const result = await resolveGoogleMapsUrl(url);
if (!result) return { content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }], isError: true };
return ok(result);
}
);
async ({ url }) => {
const result = await resolveGoogleMapsUrl(url);
if (!result)
return {
content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }],
isError: true,
};
return ok(result);
},
);
// --- WEATHER ---
if (canWeather) server.registerTool(
'get_weather',
{
description: 'Get weather forecast for a location and date.',
inputSchema: {
lat: z.number(),
lng: z.number(),
date: z.string().describe('ISO date YYYY-MM-DD'),
lang: z.string().optional().default('en'),
if (canWeather)
server.registerTool(
'get_weather',
{
description: 'Get weather forecast for a location and date.',
inputSchema: {
lat: z.number(),
lng: z.number(),
date: z.string().describe('ISO date YYYY-MM-DD'),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ lat, lng, date, lang }) => {
try {
const weather = await getWeather(String(lat), String(lng), date, lang ?? 'en');
return ok({ weather });
} catch (err: any) {
return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true };
}
}
);
async ({ lat, lng, date, lang }) => {
try {
const weather = await getWeather(String(lat), String(lng), date, lang ?? 'en');
return ok({ weather });
} catch (err: any) {
return {
content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }],
isError: true,
};
}
},
);
if (canWeather) server.registerTool(
'get_detailed_weather',
{
description: 'Get hourly/detailed weather forecast for a location and date.',
inputSchema: {
lat: z.number(),
lng: z.number(),
date: z.string().describe('ISO date YYYY-MM-DD'),
lang: z.string().optional().default('en'),
if (canWeather)
server.registerTool(
'get_detailed_weather',
{
description: 'Get hourly/detailed weather forecast for a location and date.',
inputSchema: {
lat: z.number(),
lng: z.number(),
date: z.string().describe('ISO date YYYY-MM-DD'),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ lat, lng, date, lang }) => {
try {
const weather = await getDetailedWeather(String(lat), String(lng), date, lang ?? 'en');
return ok({ weather });
} catch (err: any) {
return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true };
}
}
);
async ({ lat, lng, date, lang }) => {
try {
const weather = await getDetailedWeather(String(lat), String(lng), date, lang ?? 'en');
return ok({ weather });
} catch (err: any) {
return {
content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }],
isError: true,
};
}
},
);
// --- AIRPORTS ---
if (canGeo) server.registerTool(
'search_airports',
{
description: 'Search for airports by name, city, or IATA code. Returns matching airports with IATA code, name, city, country, coordinates, and timezone. Use before create_transport (flight) to get the correct IATA code and timezone for endpoints.',
inputSchema: {
query: z.string().min(1).max(200).describe('Airport name, city, or IATA code (e.g. "zurich", "ZRH", "charles de gaulle")'),
limit: z.number().int().min(1).max(50).optional().default(10),
if (canGeo)
server.registerTool(
'search_airports',
{
description:
'Search for airports by name, city, or IATA code. Returns matching airports with IATA code, name, city, country, coordinates, and timezone. Use before create_transport (flight) to get the correct IATA code and timezone for endpoints.',
inputSchema: {
query: z
.string()
.min(1)
.max(200)
.describe('Airport name, city, or IATA code (e.g. "zurich", "ZRH", "charles de gaulle")'),
limit: z.number().int().min(1).max(50).optional().default(10),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ query, limit }) => {
const airports = searchAirports(query, limit ?? 10);
return ok({ airports });
}
);
async ({ query, limit }) => {
const airports = searchAirports(query, limit ?? 10);
return ok({ airports });
},
);
if (canGeo) server.registerTool(
'get_airport',
{
description: 'Get a single airport by its IATA code. Returns name, city, country, coordinates, and timezone.',
inputSchema: {
iata: z.string().length(3).toUpperCase().describe('IATA airport code (e.g. "ZRH", "AMS", "CDG")'),
if (canGeo)
server.registerTool(
'get_airport',
{
description: 'Get a single airport by its IATA code. Returns name, city, country, coordinates, and timezone.',
inputSchema: {
iata: z.string().length(3).toUpperCase().describe('IATA airport code (e.g. "ZRH", "AMS", "CDG")'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ iata }) => {
const airport = findByIata(iata);
if (!airport) return { content: [{ type: 'text' as const, text: 'Airport not found.' }], isError: true };
return ok({ airport });
}
);
async ({ iata }) => {
const airport = findByIata(iata);
if (!airport) return { content: [{ type: 'text' as const, text: 'Airport not found.' }], isError: true };
return ok({ airport });
},
);
}
+96 -81
View File
@@ -1,17 +1,23 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import {
getNotifications, getUnreadCount,
markRead as markNotificationRead, markUnread as markNotificationUnread,
getNotifications,
getUnreadCount,
markRead as markNotificationRead,
markUnread as markNotificationUnread,
markAllRead,
} from '../../services/inAppNotifications';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import {
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerNotificationTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'notifications');
@@ -19,81 +25,90 @@ export function registerNotificationTools(server: McpServer, userId: number, sco
// --- NOTIFICATIONS ---
if (R) server.registerTool(
'list_notifications',
{
description: 'List in-app notifications for the current user.',
inputSchema: {
limit: z.number().int().positive().optional().default(20),
offset: z.number().int().min(0).optional().default(0),
unread_only: z.boolean().optional().default(false),
if (R)
server.registerTool(
'list_notifications',
{
description: 'List in-app notifications for the current user.',
inputSchema: {
limit: z.number().int().positive().optional().default(20),
offset: z.number().int().min(0).optional().default(0),
unread_only: z.boolean().optional().default(false),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ limit, offset, unread_only }) => {
const result = getNotifications(userId, { limit: limit ?? 20, offset: offset ?? 0, unreadOnly: unread_only ?? false });
return ok(result);
}
);
if (R) server.registerTool(
'get_unread_notification_count',
{
description: 'Get the number of unread in-app notifications.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const count = getUnreadCount(userId);
return ok({ count });
}
);
if (W) server.registerTool(
'mark_notification_read',
{
description: 'Mark a single notification as read.',
inputSchema: {
notificationId: z.number().int().positive(),
async ({ limit, offset, unread_only }) => {
const result = getNotifications(userId, {
limit: limit ?? 20,
offset: offset ?? 0,
unreadOnly: unread_only ?? false,
});
return ok(result);
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ notificationId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = markNotificationRead(notificationId, userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
}
);
);
if (W) server.registerTool(
'mark_notification_unread',
{
description: 'Mark a single notification as unread.',
inputSchema: {
notificationId: z.number().int().positive(),
if (R)
server.registerTool(
'get_unread_notification_count',
{
description: 'Get the number of unread in-app notifications.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ notificationId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = markNotificationUnread(notificationId, userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
}
);
async () => {
const count = getUnreadCount(userId);
return ok({ count });
},
);
if (W) server.registerTool(
'mark_all_notifications_read',
{
description: "Mark all of the current user's notifications as read.",
inputSchema: {},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async () => {
if (isDemoUser(userId)) return demoDenied();
const count = markAllRead(userId);
return ok({ success: true, count });
}
);
if (W)
server.registerTool(
'mark_notification_read',
{
description: 'Mark a single notification as read.',
inputSchema: {
notificationId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ notificationId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = markNotificationRead(notificationId, userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
},
);
if (W)
server.registerTool(
'mark_notification_unread',
{
description: 'Mark a single notification as unread.',
inputSchema: {
notificationId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ notificationId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = markNotificationUnread(notificationId, userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
},
);
if (W)
server.registerTool(
'mark_all_notifications_read',
{
description: "Mark all of the current user's notifications as read.",
inputSchema: {},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async () => {
if (isDemoUser(userId)) return demoDenied();
const count = markAllRead(userId);
return ok({ success: true, count });
},
);
}
+323 -282
View File
@@ -1,24 +1,37 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { canAccessTrip } from '../../db/database';
import { isAddonEnabled } from '../../services/adminService';
import { isDemoUser } from '../../services/authService';
import {
createItem as createPackingItem, updateItem as updatePackingItem,
createItem as createPackingItem,
updateItem as updatePackingItem,
deleteItem as deletePackingItem,
reorderItems as reorderPackingItems,
listBags, createBag, updateBag, deleteBag, setBagMembers,
listBags,
createBag,
updateBag,
deleteBag,
setBagMembers,
getCategoryAssignees as getPackingCategoryAssignees,
updateCategoryAssignees as updatePackingCategoryAssignees,
applyTemplate, saveAsTemplate, bulkImport,
applyTemplate,
saveAsTemplate,
bulkImport,
} from '../../services/packingService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'packing');
@@ -28,307 +41,335 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
// --- PACKING ---
if (W) server.registerTool(
'create_packing_item',
{
description: 'Add an item to the packing checklist for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
if (W)
server.registerTool(
'create_packing_item',
{
description: 'Add an item to the packing checklist for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createPackingItem(tripId, { name, category: category || 'General' });
safeBroadcast(tripId, 'packing:created', { item });
return ok({ item });
}
);
async ({ tripId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createPackingItem(tripId, { name, category: category || 'General' });
safeBroadcast(tripId, 'packing:created', { item });
return ok({ item });
},
);
if (W) server.registerTool(
'toggle_packing_item',
{
description: 'Check or uncheck a packing item.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
checked: z.boolean(),
if (W)
server.registerTool(
'toggle_packing_item',
{
description: 'Check or uncheck a packing item.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
checked: z.boolean(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item });
}
);
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item });
},
);
if (W) server.registerTool(
'delete_packing_item',
{
description: 'Remove an item from the packing checklist.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
if (W)
server.registerTool(
'delete_packing_item',
{
description: 'Remove an item from the packing checklist.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePackingItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:deleted', { itemId });
return ok({ success: true });
}
);
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePackingItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:deleted', { itemId });
return ok({ success: true });
},
);
// --- PACKING (update) ---
if (W) server.registerTool(
'update_packing_item',
{
description: 'Rename a packing item or change its category.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
if (W)
server.registerTool(
'update_packing_item',
{
description: 'Rename a packing item or change its category.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item });
}
);
async ({ tripId, itemId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const bodyKeys = ['name', 'category'].filter((k) =>
k === 'name' ? name !== undefined : category !== undefined,
);
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item });
},
);
// --- PACKING ADVANCED ---
if (W) server.registerTool(
'reorder_packing_items',
{
description: 'Set the display order of packing items within a trip.',
inputSchema: {
tripId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()).describe('Packing item IDs in desired order'),
if (W)
server.registerTool(
'reorder_packing_items',
{
description: 'Set the display order of packing items within a trip.',
inputSchema: {
tripId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()).describe('Packing item IDs in desired order'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
reorderPackingItems(tripId, orderedIds);
safeBroadcast(tripId, 'packing:reordered', { orderedIds });
return ok({ success: true });
}
);
async ({ tripId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
reorderPackingItems(tripId, orderedIds);
safeBroadcast(tripId, 'packing:reordered', { orderedIds });
return ok({ success: true });
},
);
if (R) server.registerTool(
'list_packing_bags',
{
description: 'List all packing bags for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
if (R)
server.registerTool(
'list_packing_bags',
{
description: 'List all packing bags for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const bags = listBags(tripId);
return ok({ bags });
}
);
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const bags = listBags(tripId);
return ok({ bags });
},
);
if (W) server.registerTool(
'create_packing_bag',
{
description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(100),
color: z.string().optional(),
if (W)
server.registerTool(
'create_packing_bag',
{
description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(100),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const bag = createBag(tripId, { name, color });
safeBroadcast(tripId, 'packing:bag-created', { bag });
return ok({ bag });
}
);
async ({ tripId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const bag = createBag(tripId, { name, color });
safeBroadcast(tripId, 'packing:bag-created', { bag });
return ok({ bag });
},
);
if (W) server.registerTool(
'update_packing_bag',
{
description: 'Rename or recolor a packing bag.',
inputSchema: {
tripId: z.number().int().positive(),
bagId: z.number().int().positive(),
name: z.string().optional(),
color: z.string().optional(),
if (W)
server.registerTool(
'update_packing_bag',
{
description: 'Rename or recolor a packing bag.',
inputSchema: {
tripId: z.number().int().positive(),
bagId: z.number().int().positive(),
name: z.string().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, bagId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const fields: Record<string, unknown> = {};
const bodyKeys: string[] = [];
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
const bag = updateBag(tripId, bagId, fields, bodyKeys);
safeBroadcast(tripId, 'packing:bag-updated', { bag });
return ok({ bag });
}
);
async ({ tripId, bagId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const fields: Record<string, unknown> = {};
const bodyKeys: string[] = [];
if (name !== undefined) {
fields.name = name;
bodyKeys.push('name');
}
if (color !== undefined) {
fields.color = color;
bodyKeys.push('color');
}
const bag = updateBag(tripId, bagId, fields, bodyKeys);
safeBroadcast(tripId, 'packing:bag-updated', { bag });
return ok({ bag });
},
);
if (W) server.registerTool(
'delete_packing_bag',
{
description: 'Delete a packing bag (items in the bag are unassigned, not deleted).',
inputSchema: {
tripId: z.number().int().positive(),
bagId: z.number().int().positive(),
if (W)
server.registerTool(
'delete_packing_bag',
{
description: 'Delete a packing bag (items in the bag are unassigned, not deleted).',
inputSchema: {
tripId: z.number().int().positive(),
bagId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, bagId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
deleteBag(tripId, bagId);
safeBroadcast(tripId, 'packing:bag-deleted', { id: bagId });
return ok({ success: true });
}
);
async ({ tripId, bagId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
deleteBag(tripId, bagId);
safeBroadcast(tripId, 'packing:bag-deleted', { id: bagId });
return ok({ success: true });
},
);
if (W) server.registerTool(
'set_bag_members',
{
description: 'Assign trip members to a packing bag (determines who packs what bag).',
inputSchema: {
tripId: z.number().int().positive(),
bagId: z.number().int().positive(),
userIds: z.array(z.number().int().positive()),
if (W)
server.registerTool(
'set_bag_members',
{
description: 'Assign trip members to a packing bag (determines who packs what bag).',
inputSchema: {
tripId: z.number().int().positive(),
bagId: z.number().int().positive(),
userIds: z.array(z.number().int().positive()),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, bagId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
setBagMembers(tripId, bagId, userIds);
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
return ok({ success: true });
}
);
async ({ tripId, bagId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
setBagMembers(tripId, bagId, userIds);
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
return ok({ success: true });
},
);
if (R) server.registerTool(
'get_packing_category_assignees',
{
description: 'Get which trip members are assigned to each packing category.',
inputSchema: {
tripId: z.number().int().positive(),
if (R)
server.registerTool(
'get_packing_category_assignees',
{
description: 'Get which trip members are assigned to each packing category.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignees = getPackingCategoryAssignees(tripId);
return ok({ assignees });
}
);
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignees = getPackingCategoryAssignees(tripId);
return ok({ assignees });
},
);
if (W) server.registerTool(
'set_packing_category_assignees',
{
description: 'Assign trip members to a packing category.',
inputSchema: {
tripId: z.number().int().positive(),
categoryName: z.string().min(1).max(100),
userIds: z.array(z.number().int().positive()),
if (W)
server.registerTool(
'set_packing_category_assignees',
{
description: 'Assign trip members to a packing category.',
inputSchema: {
tripId: z.number().int().positive(),
categoryName: z.string().min(1).max(100),
userIds: z.array(z.number().int().positive()),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, categoryName, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
updatePackingCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
return ok({ success: true });
}
);
async ({ tripId, categoryName, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
updatePackingCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
return ok({ success: true });
},
);
if (W) server.registerTool(
'apply_packing_template',
{
description: 'Apply a packing template to a trip (adds items from the template).',
inputSchema: {
tripId: z.number().int().positive(),
templateId: z.number().int().positive(),
if (W)
server.registerTool(
'apply_packing_template',
{
description: 'Apply a packing template to a trip (adds items from the template).',
inputSchema: {
tripId: z.number().int().positive(),
templateId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, templateId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const applied = applyTemplate(tripId, templateId);
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
safeBroadcast(tripId, 'packing:template-applied', { templateId });
return ok({ success: true });
}
);
async ({ tripId, templateId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const applied = applyTemplate(tripId, templateId);
if (applied === null)
return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
safeBroadcast(tripId, 'packing:template-applied', { templateId });
return ok({ success: true });
},
);
if (W) server.registerTool(
'save_packing_template',
{
description: 'Save the current packing list as a reusable template.',
inputSchema: {
tripId: z.number().int().positive(),
templateName: z.string().min(1).max(100),
if (W)
server.registerTool(
'save_packing_template',
{
description: 'Save the current packing list as a reusable template.',
inputSchema: {
tripId: z.number().int().positive(),
templateName: z.string().min(1).max(100),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, templateName }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
saveAsTemplate(tripId, userId, templateName);
return ok({ success: true });
}
);
async ({ tripId, templateName }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
saveAsTemplate(tripId, userId, templateName);
return ok({ success: true });
},
);
if (W) server.registerTool(
'bulk_import_packing',
{
description: 'Import multiple packing items at once from a list.',
inputSchema: {
tripId: z.number().int().positive(),
items: z.array(z.object({
name: z.string().min(1).max(200),
category: z.string().optional(),
quantity: z.number().int().positive().optional(),
})).min(1),
if (W)
server.registerTool(
'bulk_import_packing',
{
description: 'Import multiple packing items at once from a list.',
inputSchema: {
tripId: z.number().int().positive(),
items: z
.array(
z.object({
name: z.string().min(1).max(200),
category: z.string().optional(),
quantity: z.number().int().positive().optional(),
}),
)
.min(1),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, items }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
bulkImport(tripId, items);
safeBroadcast(tripId, 'packing:updated', {});
return ok({ success: true, count: items.length });
}
);
async ({ tripId, items }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
bulkImport(tripId, items);
safeBroadcast(tripId, 'packing:updated', {});
return ok({ success: true, count: items.length });
},
);
}
+409 -231
View File
@@ -1,18 +1,32 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip, db } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
import { createAssignment, dayExists } from '../../services/assignmentService';
import { onPlaceDeleted } from '../../services/journeyService';
import { isDemoUser } from '../../services/authService';
import { listCategories } from '../../services/categoryService';
import { onPlaceDeleted } from '../../services/journeyService';
import { searchPlaces } from '../../services/mapsService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
deletePlacesMany,
importGoogleList,
importNaverList,
listPlaces,
createPlace,
updatePlace,
deletePlace,
} from '../../services/placeService';
import { canRead, canWrite } from '../scopes';
import {
safeBroadcast,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerPlaceTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
@@ -20,244 +34,408 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
// --- PLACES ---
if (W) server.registerTool(
'create_place',
{
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
if (W)
server.registerTool(
'create_place',
{
description:
'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z
.number()
.int()
.positive()
.optional()
.describe('Category ID — use list_categories to see available options'),
google_place_id: z
.string()
.optional()
.describe('Google Place ID from search_place — enables opening hours display'),
osm_id: z
.string()
.optional()
.describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
price: z
.number()
.nonnegative()
.optional()
.describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
);
if (W) server.registerTool(
'create_and_assign_place',
{
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive().describe('Day to assign the place to'),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try {
const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment };
async ({
tripId,
name,
description,
lat,
lng,
address,
category_id,
google_place_id,
osm_id,
notes,
website,
phone,
price,
currency,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const place = createPlace(String(tripId), {
name,
description,
lat,
lng,
address,
category_id,
google_place_id,
osm_id,
notes,
website,
phone,
price,
currency,
});
const result = run();
safeBroadcast(tripId, 'place:created', { place: result.place });
safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment });
return ok(result);
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true };
}
}
);
if (W) server.registerTool(
'update_place',
{
description: 'Update an existing place in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
placeId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
price: z.number().optional(),
currency: z.string().length(3).optional(),
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
duration_minutes: z.number().int().positive().optional(),
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:updated', { place });
return ok({ place });
}
);
);
if (W) server.registerTool(
'delete_place',
{
description: 'Delete a place from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
placeId: z.number().int().positive(),
if (W)
server.registerTool(
'create_and_assign_place',
{
description:
'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive().describe('Day to assign the place to'),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z
.number()
.int()
.positive()
.optional()
.describe('Category ID — use list_categories to see available options'),
google_place_id: z
.string()
.optional()
.describe('Google Place ID from search_place — enables opening hours display'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
price: z
.number()
.nonnegative()
.optional()
.describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, placeId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePlace(String(tripId), String(placeId));
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:deleted', { placeId });
return ok({ success: true });
}
);
async ({
tripId,
dayId,
name,
description,
lat,
lng,
address,
category_id,
google_place_id,
osm_id,
place_notes,
website,
phone,
assignment_notes,
price,
currency,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayExists(dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try {
const run = db.transaction(() => {
const place = createPlace(String(tripId), {
name,
description,
lat,
lng,
address,
category_id,
google_place_id,
osm_id,
notes: place_notes,
website,
phone,
price,
currency,
});
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment };
});
const result = run();
safeBroadcast(tripId, 'place:created', { place: result.place });
safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment });
return ok(result);
} catch {
return {
content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }],
isError: true,
};
}
},
);
if (R) server.registerTool(
'list_places',
{
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
inputSchema: {
tripId: z.number().int().positive(),
search: z.string().optional(),
category: z.string().optional(),
tag: z.string().optional(),
assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all').describe('Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)'),
if (W)
server.registerTool(
'update_place',
{
description: 'Update an existing place in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
placeId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
price: z.number().optional(),
currency: z.string().length(3).optional(),
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
duration_minutes: z.number().int().positive().optional(),
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, search, category, tag, assignment }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const places = listPlaces(String(tripId), { search, category, tag, assignment });
return ok({ places });
}
);
async ({
tripId,
placeId,
name,
description,
lat,
lng,
address,
category_id,
price,
currency,
place_time,
end_time,
duration_minutes,
notes,
website,
phone,
transport_mode,
osm_id,
google_place_id,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const place = updatePlace(String(tripId), String(placeId), {
name,
description,
lat,
lng,
address,
category_id,
price,
currency,
place_time,
end_time,
duration_minutes,
notes,
website,
phone,
transport_mode,
osm_id,
google_place_id,
});
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:updated', { place });
return ok({ place });
},
);
if (W)
server.registerTool(
'delete_place',
{
description: 'Delete a place from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
placeId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, placeId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePlace(String(tripId), String(placeId));
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:deleted', { placeId });
return ok({ success: true });
},
);
if (R)
server.registerTool(
'list_places',
{
description:
'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
inputSchema: {
tripId: z.number().int().positive(),
search: z.string().optional(),
category: z.string().optional(),
tag: z.string().optional(),
assignment: z
.enum(['all', 'unassigned', 'assigned'])
.optional()
.default('all')
.describe(
'Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)',
),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, search, category, tag, assignment }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const places = listPlaces(String(tripId), { search, category, tag, assignment });
return ok({ places });
},
);
// --- 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 });
}
);
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 });
},
);
// --- SEARCH ---
if (R) server.registerTool(
'search_place',
{
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'),
if (R)
server.registerTool(
'search_place',
{
description:
'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ query }) => {
try {
const result = await searchPlaces(userId, query);
return ok(result);
} catch {
return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true };
}
}
);
if (W) server.registerTool(
'import_places_from_url',
{
description: 'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.',
inputSchema: {
tripId: z.number().int().positive(),
url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'),
source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'),
async ({ query }) => {
try {
const result = await searchPlaces(userId, query);
return ok(result);
} catch {
return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true };
}
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, url, source }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
);
const result = source === 'google-list'
? await importGoogleList(String(tripId), url)
: await importNaverList(String(tripId), url);
if ('error' in result) {
return { content: [{ type: 'text' as const, text: result.error }], isError: true };
}
for (const place of result.places) {
safeBroadcast(tripId, 'place:created', { place });
}
return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
}
);
if (W) server.registerTool(
'bulk_delete_places',
{
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.',
inputSchema: {
tripId: z.number().int().positive(),
placeIds: z.array(z.number().int().positive()).min(1).max(200),
if (W)
server.registerTool(
'import_places_from_url',
{
description:
'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.',
inputSchema: {
tripId: z.number().int().positive(),
url: z
.string()
.url()
.describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'),
source: z
.enum(['google-list', 'naver-list'])
.describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, placeIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
async ({ tripId, url, source }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePlacesMany(String(tripId), placeIds);
for (const id of deleted) {
safeBroadcast(tripId, 'place:deleted', { placeId: id });
try { onPlaceDeleted(id); } catch {}
}
return ok({ deleted, count: deleted.length });
}
);
const result =
source === 'google-list'
? await importGoogleList(String(tripId), url)
: await importNaverList(String(tripId), url);
if ('error' in result) {
return { content: [{ type: 'text' as const, text: result.error }], isError: true };
}
for (const place of result.places) {
safeBroadcast(tripId, 'place:created', { place });
}
return ok({
places: result.places,
count: result.places.length,
listName: result.listName,
skipped: result.skipped,
});
},
);
if (W)
server.registerTool(
'bulk_delete_places',
{
description:
'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.',
inputSchema: {
tripId: z.number().int().positive(),
placeIds: z.array(z.number().int().positive()).min(1).max(200),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, placeIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePlacesMany(String(tripId), placeIds);
for (const id of deleted) {
safeBroadcast(tripId, 'place:deleted', { placeId: id });
try {
onPlaceDeleted(id);
} catch {}
}
return ok({ deleted, count: deleted.length });
},
);
}
+111 -82
View File
@@ -1,10 +1,11 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { getTripSummary } from '../../services/tripService';
import { listItems as listPackingItems } from '../../services/packingService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { canAccessTrip } from '../../db/database';
import { isAddonEnabled } from '../../services/adminService';
import { listItems as listPackingItems } from '../../services/packingService';
import { getTripSummary } from '../../services/tripService';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerMcpPrompts(server: McpServer, _userId: number, isStaticToken = false): void {
if (isStaticToken) {
@@ -17,14 +18,16 @@ export function registerMcpPrompts(server: McpServer, _userId: number, isStaticT
},
async () => ({
description: 'Static token deprecation notice',
messages: [{
role: 'user' as const,
content: {
type: 'text' as const,
text: '⚠️ This MCP connection is authenticated with a static API token (trek_…). Static token authentication will be deprecated in a future version of TREK. Please inform the user that they should migrate to OAuth 2.1 by going to Settings → Integrations → MCP → OAuth Clients in TREK and registering an OAuth 2.1 application for their MCP client.',
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: '⚠️ This MCP connection is authenticated with a static API token (trek_…). Static token authentication will be deprecated in a future version of TREK. Please inform the user that they should migrate to OAuth 2.1 by going to Settings → Integrations → MCP → OAuth Clients in TREK and registering an OAuth 2.1 application for their MCP client.',
},
},
}],
})
],
}),
);
}
const userId = _userId;
@@ -47,7 +50,9 @@ export function registerMcpPrompts(server: McpServer, _userId: number, isStaticT
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, days, members, budget, packing, reservations, collabNotes } = summary;
const packingStats = packing ? { total: packing.length, packed: packing.filter((p: any) => p.checked).length } : { total: 0, packed: 0 };
const packingStats = packing
? { total: packing.length, packed: packing.filter((p: any) => p.checked).length }
: { total: 0, packed: 0 };
const budgetTotal = budget?.reduce((sum: number, b: any) => sum + (b.total_price || 0), 0) || 0;
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
@@ -62,77 +67,101 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
description: `Summary of trip "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text } }],
};
}
},
);
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerPrompt(
'packing-list',
{
title: 'Packing List',
description: 'Get a formatted packing checklist for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
if (isAddonEnabled(ADDON_IDS.PACKING))
server.registerPrompt(
'packing-list',
{
title: 'Packing List',
description: 'Get a formatted packing checklist for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const items = listPackingItems(tripId);
if (!items.length) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] };
}
const grouped = items.reduce((acc: Record<string, any[]>, item: any) => {
const cat = item.category || 'General';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(item);
return acc;
}, {});
const lines = Object.entries(grouped).map(([cat, items]) =>
`## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
).join('\n\n');
const { trip } = getTripSummary(tripId) || {};
return {
description: `Packing list for "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
};
}
);
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const items = listPackingItems(tripId);
if (!items.length) {
return {
messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }],
};
}
const grouped = items.reduce((acc: Record<string, any[]>, item: any) => {
const cat = item.category || 'General';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(item);
return acc;
}, {});
const lines = Object.entries(grouped)
.map(
([cat, items]) =>
`## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`,
)
.join('\n\n');
const { trip } = getTripSummary(tripId) || {};
return {
description: `Packing list for "${trip?.title || tripId}"`,
messages: [
{
role: 'user',
content: {
type: 'text',
text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_`,
},
},
],
};
},
);
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerPrompt(
'budget-overview',
{
title: 'Budget Overview',
description: 'Get a formatted budget summary for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
if (isAddonEnabled(ADDON_IDS.BUDGET))
server.registerPrompt(
'budget-overview',
{
title: 'Budget Overview',
description: 'Get a formatted budget summary for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId);
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, budget } = summary;
const currency = trip?.currency || 'EUR';
const byCategory = (budget || []).reduce((acc: Record<string, number>, item: any) => {
const cat = item.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
return acc;
}, {} as Record<string, number>);
const total = Object.values(byCategory).reduce((s, v) => s + v, 0);
const lines = Object.entries(byCategory)
.sort(([, a], [, b]) => b - a)
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
.join('\n');
const perPerson = (summary.members?.length || 1) > 0 ? (total / (summary.members?.length || 1)).toFixed(2) : total.toFixed(2);
return {
description: `Budget overview for "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }],
};
}
);
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId);
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, budget } = summary;
const currency = trip?.currency || 'EUR';
const byCategory = (budget || []).reduce((acc: Record<string, number>, item: any) => {
const cat = item.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
return acc;
}, {});
const total = Object.values(byCategory).reduce((s, v) => s + v, 0);
const lines = Object.entries(byCategory)
.sort(([, a], [, b]) => b - a)
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
.join('\n');
const perPerson =
(summary.members?.length || 1) > 0 ? (total / (summary.members?.length || 1)).toFixed(2) : total.toFixed(2);
return {
description: `Budget overview for "${trip?.title || tripId}"`,
messages: [
{
role: 'user',
content: {
type: 'text',
text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}`,
},
},
],
};
},
);
}
+204 -60
View File
@@ -1,50 +1,100 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
import { isDemoUser } from '../../services/authService';
import {
createReservation, getReservation, updateReservation, deleteReservation,
updatePositions as updateReservationPositions,
} from '../../services/reservationService';
import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService';
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
createReservation,
getReservation,
updateReservation,
deleteReservation,
updatePositions as updateReservationPositions,
} from '../../services/reservationService';
import { canWrite } from '../scopes';
import {
safeBroadcast,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerReservationTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'reservations')) return;
server.registerTool(
'create_reservation',
{
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
description:
'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
type: z
.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other'])
.describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
location: z.string().max(500).optional(),
confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
day_id: z.number().int().positive().optional(),
place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'),
start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
start_day_id: z
.number()
.int()
.positive()
.optional()
.describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
end_day_id: z
.number()
.int()
.positive()
.optional()
.describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
assignment_id: z
.number()
.int()
.positive()
.optional()
.describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
price: z
.number()
.nonnegative()
.optional()
.describe('Reservation cost — shown on the booking and linked in the Budget tab'),
budget_category: z
.string()
.max(100)
.optional()
.describe('Budget category for the price entry (defaults to reservation type)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
async ({
tripId,
title,
type,
reservation_time,
location,
confirmation_number,
notes,
day_id,
place_id,
start_day_id,
end_day_id,
check_in,
check_out,
assignment_id,
price,
budget_category,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -54,21 +104,45 @@ export function registerReservationTools(server: McpServer, userId: number, scop
if (place_id && !placeExists(place_id, tripId))
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
if (start_day_id && !getDay(start_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }],
isError: true,
};
if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }],
isError: true,
};
if (assignment_id && !getAssignmentForTrip(assignment_id, tripId))
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }],
isError: true,
};
const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id)
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
: undefined;
const createAccommodation =
type === 'hotel' && place_id && start_day_id && end_day_id
? {
place_id,
start_day_id,
end_day_id,
check_in: check_in || undefined,
check_out: check_out || undefined,
confirmation: confirmation_number || undefined,
}
: undefined;
const metadata = price != null ? { price: String(price) } : undefined;
const { reservation, accommodationCreated } = createReservation(tripId, {
title, type, reservation_time, location, confirmation_number,
notes, day_id, place_id, assignment_id,
title,
type,
reservation_time,
location,
confirmation_number,
notes,
day_id,
place_id,
assignment_id,
create_accommodation: createAccommodation,
metadata,
});
@@ -88,29 +162,62 @@ export function registerReservationTools(server: McpServer, userId: number, scop
safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation });
}
},
);
server.registerTool(
'update_reservation',
{
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For flights, trains, cars, and cruises, use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/other → use assignment_id to link to a day assignment.',
description:
'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For flights, trains, cars, and cruises, use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/other → use assignment_id to link to a day assignment.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
type: z
.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other'])
.optional()
.describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
location: z.string().max(500).optional(),
confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'),
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
status: z
.enum(['pending', 'confirmed', 'cancelled'])
.optional()
.describe('Reservation status: "pending", "confirmed", or "cancelled"'),
place_id: z
.number()
.int()
.positive()
.nullable()
.optional()
.describe('Link to a place (use for hotel type), or null to unlink'),
assignment_id: z
.number()
.int()
.positive()
.nullable()
.optional()
.describe(
'Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink',
),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
async ({
tripId,
reservationId,
title,
type,
reservation_time,
location,
confirmation_number,
notes,
status,
place_id,
assignment_id,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const existing = getReservation(reservationId, tripId);
@@ -119,16 +226,30 @@ export function registerReservationTools(server: McpServer, userId: number, scop
if (place_id != null && !placeExists(place_id, tripId))
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
if (assignment_id != null && !getAssignmentForTrip(assignment_id, tripId))
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }],
isError: true,
};
const { reservation } = updateReservation(reservationId, tripId, {
title, type, reservation_time, location, confirmation_number, notes, status,
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
}, existing);
const { reservation } = updateReservation(
reservationId,
tripId,
{
title,
type,
reservation_time,
location,
confirmation_number,
notes,
status,
place_id: place_id !== undefined ? (place_id ?? undefined) : undefined,
assignment_id: assignment_id !== undefined ? (assignment_id ?? undefined) : undefined,
},
existing,
);
safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation });
}
},
);
server.registerTool(
@@ -151,7 +272,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
}
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
return ok({ success: true });
}
},
);
server.registerTool(
@@ -160,10 +281,14 @@ export function registerReservationTools(server: McpServer, userId: number, scop
description: 'Update the display order of reservations within a day.',
inputSchema: {
tripId: z.number().int().positive(),
positions: z.array(z.object({
id: z.number().int().positive(),
day_plan_position: z.number().int().min(0),
})).describe('Array of { id, day_plan_position } pairs'),
positions: z
.array(
z.object({
id: z.number().int().positive(),
day_plan_position: z.number().int().min(0),
}),
)
.describe('Array of { id, day_plan_position } pairs'),
dayId: z.number().int().positive().optional().describe('Optionally scope the update to a specific day'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
@@ -174,13 +299,14 @@ export function registerReservationTools(server: McpServer, userId: number, scop
updateReservationPositions(tripId, positions, dayId);
safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
return ok({ success: true });
}
},
);
server.registerTool(
'link_hotel_accommodation',
{
description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
description:
'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
@@ -197,26 +323,44 @@ export function registerReservationTools(server: McpServer, userId: number, scop
if (!canAccessTrip(tripId, userId)) return noAccess();
const current = getReservation(reservationId, tripId);
if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
if (current.type !== 'hotel')
return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
if (!placeExists(place_id, tripId))
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
if (!getDay(start_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }],
isError: true,
};
if (!getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }],
isError: true,
};
const isNewAccommodation = !current.accommodation_id;
const { reservation } = updateReservation(reservationId, tripId, {
place_id,
type: current.type,
status: current.status as string,
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
}, current);
const { reservation } = updateReservation(
reservationId,
tripId,
{
place_id,
type: current.type,
status: current.status,
create_accommodation: {
place_id,
start_day_id,
end_day_id,
check_in: check_in || undefined,
check_out: check_out || undefined,
},
},
current,
);
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
}
return ok({ reservation, accommodation_id: reservation.accommodation_id });
},
);
}
+79 -69
View File
@@ -1,13 +1,17 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../services/tagService';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import {
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerTagTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
@@ -15,70 +19,76 @@ export function registerTagTools(server: McpServer, userId: number, scopes: stri
// --- TAGS ---
if (R) server.registerTool(
'list_tags',
{
description: 'List all tags belonging to the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const tags = listTags(userId);
return ok({ tags });
}
);
if (W) server.registerTool(
'create_tag',
{
description: 'Create a new tag (user-scoped label for places).',
inputSchema: {
name: z.string().min(1).max(100),
color: z.string().optional().describe('Hex color string e.g. #6366f1'),
if (R)
server.registerTool(
'list_tags',
{
description: 'List all tags belonging to the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ name, color }) => {
if (isDemoUser(userId)) return demoDenied();
const tag = createTag(userId, name, color);
return ok({ tag });
}
);
if (W) server.registerTool(
'update_tag',
{
description: 'Update the name or color of an existing tag.',
inputSchema: {
tagId: z.number().int().positive(),
name: z.string().optional(),
color: z.string().optional(),
async () => {
const tags = listTags(userId);
return ok({ tags });
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tagId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!getTagByIdAndUser(tagId, userId)) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
const tag = updateTag(tagId, name, color);
if (!tag) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
return ok({ tag });
}
);
);
if (W) server.registerTool(
'delete_tag',
{
description: 'Delete a tag (removes it from all places it was attached to).',
inputSchema: {
tagId: z.number().int().positive(),
if (W)
server.registerTool(
'create_tag',
{
description: 'Create a new tag (user-scoped label for places).',
inputSchema: {
name: z.string().min(1).max(100),
color: z.string().optional().describe('Hex color string e.g. #6366f1'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tagId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!getTagByIdAndUser(tagId, userId)) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
deleteTag(tagId);
return ok({ success: true });
}
);
async ({ name, color }) => {
if (isDemoUser(userId)) return demoDenied();
const tag = createTag(userId, name, color);
return ok({ tag });
},
);
if (W)
server.registerTool(
'update_tag',
{
description: 'Update the name or color of an existing tag.',
inputSchema: {
tagId: z.number().int().positive(),
name: z.string().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tagId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!getTagByIdAndUser(tagId, userId))
return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
const tag = updateTag(tagId, name, color);
if (!tag) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
return ok({ tag });
},
);
if (W)
server.registerTool(
'delete_tag',
{
description: 'Delete a tag (removes it from all places it was attached to).',
inputSchema: {
tagId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tagId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!getTagByIdAndUser(tagId, userId))
return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
deleteTag(tagId);
return ok({ success: true });
},
);
}
+196 -163
View File
@@ -1,20 +1,30 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { canAccessTrip } from '../../db/database';
import { isAddonEnabled } from '../../services/adminService';
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,
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,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerTodoTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'todos');
@@ -24,170 +34,193 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
// --- 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(),
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,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const items = listTodoItems(tripId);
return ok({ items });
}
);
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'),
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,
},
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();
const item = createTodoItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
safeBroadcast(tripId, 'todo:created', { item });
return ok({ item });
}
);
async ({ tripId, name, category, due_date, description, assigned_user_id, priority }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
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(),
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,
},
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();
// 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 });
}
);
async ({ tripId, itemId, name, category, due_date, description, assigned_user_id, priority }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
// 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'),
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,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
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 });
}
);
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
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(),
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,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
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 });
}
);
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
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'),
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,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
reorderTodoItems(tripId, orderedIds);
return ok({ success: true });
}
);
async ({ tripId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
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(),
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,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignees = getTodoCategoryAssignees(tripId);
return ok({ assignees });
}
);
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'),
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,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, categoryName, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignees = updateTodoCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees });
return ok({ assignees });
}
);
async ({ tripId, categoryName, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignees = updateTodoCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees });
return ok({ assignees });
},
);
}
+140 -49
View File
@@ -1,31 +1,49 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createReservation, deleteReservation, getReservation, updateReservation,
} from '../../services/reservationService';
import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService';
import {
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok,
} from './_shared';
createReservation,
deleteReservation,
getReservation,
updateReservation,
} from '../../services/reservationService';
import { canWrite } from '../scopes';
import {
safeBroadcast,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_WRITE,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
const endpointSchema = z.array(z.object({
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
lat: z.number().optional(),
lng: z.number().optional(),
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
})).optional();
const endpointSchema = z
.array(
z.object({
role: z
.enum(['from', 'to', 'stop'])
.describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
code: z
.string()
.optional()
.describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
lat: z.number().optional(),
lng: z.number().optional(),
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
}),
)
.optional();
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'reservations')) return;
@@ -33,7 +51,8 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
server.registerTool(
'create_transport',
{
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
description:
'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -45,22 +64,57 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
metadata: z
.record(z.string(), z.string())
.optional()
.describe(
'Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }',
),
endpoints: endpointSchema,
needs_review: z.boolean().optional(),
price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
price: z
.number()
.nonnegative()
.optional()
.describe('Transport cost — shown on the booking and linked in the Budget tab'),
budget_category: z
.string()
.max(100)
.optional()
.describe('Budget category for the price entry (defaults to transport type)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
async ({
tripId,
type,
title,
status,
start_day_id,
end_day_id,
reservation_time,
reservation_end_time,
confirmation_number,
notes,
metadata,
endpoints,
needs_review,
price,
budget_category,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (start_day_id && !getDay(start_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }],
isError: true,
};
if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }],
isError: true,
};
const meta: Record<string, string> = { ...(metadata ?? {}) };
if (price != null) meta.price = String(price);
@@ -92,13 +146,14 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation });
}
},
);
server.registerTool(
'update_transport',
{
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates). Use status "confirmed" to confirm.',
description:
'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates). Use status "confirmed" to confirm.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
@@ -111,13 +166,33 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
metadata: z
.record(z.string(), z.string())
.optional()
.describe(
'Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }',
),
endpoints: endpointSchema,
needs_review: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
async ({
tripId,
reservationId,
type,
title,
status,
start_day_id,
end_day_id,
reservation_time,
reservation_end_time,
confirmation_number,
notes,
metadata,
endpoints,
needs_review,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -126,30 +201,46 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
const resolvedType = type ?? existing.type;
if (!(TRANSPORT_TYPES as readonly string[]).includes(resolvedType))
return { content: [{ type: 'text' as const, text: 'Reservation is not a transport type. Use update_reservation instead.' }], isError: true };
return {
content: [
{ type: 'text' as const, text: 'Reservation is not a transport type. Use update_reservation instead.' },
],
isError: true,
};
if (start_day_id && !getDay(start_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }],
isError: true,
};
if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }],
isError: true,
};
const { reservation } = updateReservation(reservationId, tripId, {
title,
type,
reservation_time,
reservation_end_time,
confirmation_number,
notes,
day_id: start_day_id,
end_day_id,
status,
metadata,
endpoints,
needs_review,
}, existing);
const { reservation } = updateReservation(
reservationId,
tripId,
{
title,
type,
reservation_time,
reservation_end_time,
confirmation_number,
notes,
day_id: start_day_id,
end_day_id,
status,
metadata,
endpoints,
needs_review,
},
existing,
);
safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation });
}
},
);
server.registerTool(
@@ -169,6 +260,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
if (!deleted) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
return ok({ success: true });
}
},
);
}
+358 -286
View File
@@ -1,32 +1,49 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
listTrips, createTrip, updateTrip, deleteTrip, getTripSummary,
isOwner, verifyTripAccess,
listMembers as listTripMembers, getTripOwner, addMember as addTripMember,
removeMember as removeTripMember,
copyTripById, exportICS, NotFoundError, ValidationError,
} from '../../services/tripService';
import {
createOrUpdateShareLink, getShareLink, deleteShareLink,
} from '../../services/shareService';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { canAccessTrip } from '../../db/database';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { isDemoUser } from '../../services/authService';
import { countMessages, listPolls } from '../../services/collabService';
import { createOrUpdateShareLink, getShareLink, deleteShareLink } from '../../services/shareService';
import { listItems as listTodoItems } from '../../services/todoService';
import {
listItems as listTodoItems,
} from '../../services/todoService';
import {
safeBroadcast, MAX_MCP_TRIP_DAYS,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
listTrips,
createTrip,
updateTrip,
deleteTrip,
getTripSummary,
isOwner,
verifyTripAccess,
listMembers as listTripMembers,
getTripOwner,
addMember as addTripMember,
removeMember as removeTripMember,
copyTripById,
exportICS,
NotFoundError,
ValidationError,
} from '../../services/tripService';
import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
import {
safeBroadcast,
MAX_MCP_TRIP_DAYS,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null, getDeprecationNotice: () => string | null = () => null): void {
import { z } from 'zod';
export function registerTripTools(
server: McpServer,
userId: number,
scopes: string[] | null,
getDeprecationNotice: () => string | null = () => null,
): void {
const R = canReadTrips(scopes);
const W = canWrite(scopes, 'trips');
const D = canDeleteTrips(scopes);
@@ -34,95 +51,130 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
// --- TRIPS ---
if (W) server.registerTool(
'create_trip',
{
description: 'Create a new trip. Returns the created trip with its generated days.',
inputSchema: {
title: z.string().min(1).max(200).describe('Trip title'),
description: z.string().max(2000).optional().describe('Trip description'),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
if (W)
server.registerTool(
'create_trip',
{
description: 'Create a new trip. Returns the created trip with its generated days.',
inputSchema: {
title: z.string().min(1).max(200).describe('Trip title'),
description: z.string().max(2000).optional().describe('Trip description'),
start_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe('Start date (YYYY-MM-DD)'),
end_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
}
const { trip } = createTrip(userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS);
return ok({ trip });
}
);
async ({ title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return {
content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }],
isError: true,
};
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return {
content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }],
isError: true,
};
}
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
}
const { trip } = createTrip(userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS);
return ok({ trip });
},
);
if (W) server.registerTool(
'update_trip',
{
description: 'Update an existing trip\'s details.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
currency: z.string().length(3).optional(),
if (W)
server.registerTool(
'update_trip',
{
description: "Update an existing trip's details.",
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
start_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
end_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
currency: z.string().length(3).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip });
}
);
async ({ tripId, title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return {
content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }],
isError: true,
};
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return {
content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }],
isError: true,
};
}
const { updatedTrip } = updateTrip(
tripId,
userId,
{ title, description, start_date, end_date, currency },
'user',
);
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip });
},
);
if (D) server.registerTool(
'delete_trip',
{
description: 'Delete a trip. Only the trip owner can delete it.',
inputSchema: {
tripId: z.number().int().positive(),
if (D)
server.registerTool(
'delete_trip',
{
description: 'Delete a trip. Only the trip owner can delete it.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!isOwner(tripId, userId)) return noAccess();
deleteTrip(tripId, userId, 'user');
return ok({ success: true, tripId });
}
);
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!isOwner(tripId, userId)) return noAccess();
deleteTrip(tripId, userId, 'user');
return ok({ success: true, tripId });
},
);
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
// they are navigation tools that any MCP client needs to discover trip IDs.
server.registerTool(
'list_trips',
{
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
description:
'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
inputSchema: {
include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
},
@@ -131,15 +183,16 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
async ({ include_archived }) => {
const notice = getDeprecationNotice();
const trips = listTrips(userId, include_archived ? null : 0);
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify({ trips }, null, 2) },
],
};
if (notice)
return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify({ trips }, null, 2) },
],
};
return ok({ trips });
}
},
);
// --- TRIP SUMMARY ---
@@ -147,7 +200,8 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
server.registerTool(
'get_trip_summary',
{
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
description:
'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
@@ -159,216 +213,234 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
if (!summary) return noAccess();
// Addon availability gates
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const collabFeatures = collabEnabled ? getCollabFeatures() : null;
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
const canReadBudget = budgetEnabled && canRead(scopes, 'budget');
const canReadBudget = budgetEnabled && canRead(scopes, 'budget');
const canReadPacking = packingEnabled && canRead(scopes, 'packing');
const canReadCollab = collabEnabled && canRead(scopes, 'collab');
const canReadTodos = packingEnabled && canRead(scopes, 'todos');
const canReadRes = canRead(scopes, 'reservations');
const canReadCollab = collabEnabled && canRead(scopes, 'collab');
const canReadTodos = packingEnabled && canRead(scopes, 'todos');
const canReadRes = canRead(scopes, 'reservations');
const todos = canReadTodos ? listTodoItems(tripId) : [];
let pollCount = 0;
let messageCount = 0;
if (canReadCollab) {
if (collabFeatures?.polls) pollCount = listPolls(tripId).length;
if (collabFeatures?.chat) messageCount = countMessages(tripId);
if (collabFeatures?.polls) pollCount = listPolls(tripId).length;
if (collabFeatures?.chat) messageCount = countMessages(tripId);
}
const notice = getDeprecationNotice();
const summaryData = {
...summary,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
};
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
],
};
if (notice)
return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
],
};
return ok(summaryData);
}
},
);
// --- TRIP MEMBERS, COPY, ICS, SHARE ---
if (R) server.registerTool(
'list_trip_members',
{
description: 'List all members of a trip (owner + collaborators).',
inputSchema: {
tripId: z.number().int().positive(),
if (R)
server.registerTool(
'list_trip_members',
{
description: 'List all members of a trip (owner + collaborators).',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow) return noAccess();
const { owner, members } = listTripMembers(tripId, ownerRow.user_id);
return ok({ owner, members });
}
);
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow) return noAccess();
const { owner, members } = listTripMembers(tripId, ownerRow.user_id);
return ok({ owner, members });
},
);
if (W) server.registerTool(
'add_trip_member',
{
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
identifier: z.string().min(1).describe('Username or email of the user to add'),
if (W)
server.registerTool(
'add_trip_member',
{
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
identifier: z.string().min(1).describe('Username or email of the user to add'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, identifier }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow || ownerRow.user_id !== userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can add members.' }], isError: true };
try {
const result = addTripMember(tripId, identifier, ownerRow.user_id, userId);
safeBroadcast(tripId, 'member:added', { member: result.member });
return ok({ member: result.member });
} catch (err) {
const msg = err instanceof ValidationError || err instanceof NotFoundError ? err.message : 'Failed to add member.';
return { content: [{ type: 'text' as const, text: msg }], isError: true };
}
}
);
async ({ tripId, identifier }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow || ownerRow.user_id !== userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can add members.' }], isError: true };
try {
const result = addTripMember(tripId, identifier, ownerRow.user_id, userId);
safeBroadcast(tripId, 'member:added', { member: result.member });
return ok({ member: result.member });
} catch (err) {
const msg =
err instanceof ValidationError || err instanceof NotFoundError ? err.message : 'Failed to add member.';
return { content: [{ type: 'text' as const, text: msg }], isError: true };
}
},
);
if (W) server.registerTool(
'remove_trip_member',
{
description: 'Remove a member from a trip. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
memberId: z.number().int().positive().describe('User ID of the member to remove'),
if (W)
server.registerTool(
'remove_trip_member',
{
description: 'Remove a member from a trip. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
memberId: z.number().int().positive().describe('User ID of the member to remove'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, memberId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow || ownerRow.user_id !== userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can remove members.' }], isError: true };
removeTripMember(tripId, memberId);
safeBroadcast(tripId, 'member:removed', { userId: memberId });
return ok({ success: true });
}
);
async ({ tripId, memberId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow || ownerRow.user_id !== userId)
return {
content: [{ type: 'text' as const, text: 'Only the trip owner can remove members.' }],
isError: true,
};
removeTripMember(tripId, memberId);
safeBroadcast(tripId, 'member:removed', { userId: memberId });
return ok({ success: true });
},
);
if (W) server.registerTool(
'copy_trip',
{
description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
inputSchema: {
tripId: z.number().int().positive().describe('Source trip ID to duplicate'),
title: z.string().min(1).max(200).optional().describe('Title for the new trip (defaults to source title)'),
if (W)
server.registerTool(
'copy_trip',
{
description:
'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
inputSchema: {
tripId: z.number().int().positive().describe('Source trip ID to duplicate'),
title: z.string().min(1).max(200).optional().describe('Title for the new trip (defaults to source title)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
try {
const newTripId = copyTripById(tripId, userId, title);
const newTrip = canAccessTrip(newTripId, userId);
return ok({ trip: { id: newTripId, ...newTrip } });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to copy trip.' }], isError: true };
}
}
);
async ({ tripId, title }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
try {
const newTripId = copyTripById(tripId, userId, title);
const newTrip = canAccessTrip(newTripId, userId);
return ok({ trip: { id: newTripId, ...newTrip } });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to copy trip.' }], isError: true };
}
},
);
if (R) server.registerTool(
'export_trip_ics',
{
description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.',
inputSchema: {
tripId: z.number().int().positive(),
if (R)
server.registerTool(
'export_trip_ics',
{
description:
"Export a trip's itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.",
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
try {
const { ics, filename } = exportICS(tripId);
return ok({ ics, filename });
} catch {
return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true };
}
}
);
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
try {
const { ics, filename } = exportICS(tripId);
return ok({ ics, filename });
} catch {
return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true };
}
},
);
if (S) server.registerTool(
'get_share_link',
{
description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
inputSchema: {
tripId: z.number().int().positive(),
if (S)
server.registerTool(
'get_share_link',
{
description:
'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const link = getShareLink(String(tripId));
return ok({ link });
}
);
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const link = getShareLink(String(tripId));
return ok({ link });
},
);
if (S) server.registerTool(
'create_share_link',
{
description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
inputSchema: {
tripId: z.number().int().positive(),
share_map: z.boolean().optional().default(true).describe('Share the map and places'),
share_bookings: z.boolean().optional().default(true).describe('Share reservations'),
share_packing: z.boolean().optional().default(false).describe('Share packing list'),
share_budget: z.boolean().optional().default(false).describe('Share budget'),
share_collab: z.boolean().optional().default(false).describe('Share collab messages'),
if (S)
server.registerTool(
'create_share_link',
{
description:
'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
inputSchema: {
tripId: z.number().int().positive(),
share_map: z.boolean().optional().default(true).describe('Share the map and places'),
share_bookings: z.boolean().optional().default(true).describe('Share reservations'),
share_packing: z.boolean().optional().default(false).describe('Share packing list'),
share_budget: z.boolean().optional().default(false).describe('Share budget'),
share_collab: z.boolean().optional().default(false).describe('Share collab messages'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const { token, created } = createOrUpdateShareLink(String(tripId), userId, {
share_map: share_map ?? true,
share_bookings: share_bookings ?? true,
share_packing: share_packing ?? false,
share_budget: share_budget ?? false,
share_collab: share_collab ?? false,
});
return ok({ token, created });
}
);
async ({ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const { token, created } = createOrUpdateShareLink(String(tripId), userId, {
share_map: share_map ?? true,
share_bookings: share_bookings ?? true,
share_packing: share_packing ?? false,
share_budget: share_budget ?? false,
share_collab: share_collab ?? false,
});
return ok({ token, created });
},
);
if (S) server.registerTool(
'delete_share_link',
{
description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
inputSchema: {
tripId: z.number().int().positive(),
if (S)
server.registerTool(
'delete_share_link',
{
description:
'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
deleteShareLink(String(tripId));
return ok({ success: true });
}
);
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
deleteShareLink(String(tripId));
return ok({ success: true });
},
);
}
+407 -360
View File
@@ -1,398 +1,445 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { isAddonEnabled } from '../../services/adminService';
import { isDemoUser, getCurrentUser } from '../../services/authService';
import {
getOwnPlan, getActivePlan, getActivePlanId, getPlanData,
updatePlan, setUserColor,
sendInvite as sendVacayInvite, acceptInvite, declineInvite, cancelInvite, dissolvePlan,
getOwnPlan,
getActivePlan,
getActivePlanId,
getPlanData,
updatePlan,
setUserColor,
sendInvite as sendVacayInvite,
acceptInvite,
declineInvite,
cancelInvite,
dissolvePlan,
getAvailableUsers,
listYears, addYear, deleteYear,
getEntries as getVacayEntries, toggleEntry, toggleCompanyHoliday,
getStats as getVacayStats, updateStats as updateVacayStats,
addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar,
getCountries as getHolidayCountries, getHolidays,
listYears,
addYear,
deleteYear,
getEntries as getVacayEntries,
toggleEntry,
toggleCompanyHoliday,
getStats as getVacayStats,
updateStats as updateVacayStats,
addHolidayCalendar,
updateHolidayCalendar,
deleteHolidayCalendar,
getCountries as getHolidayCountries,
getHolidays,
} from '../../services/vacayService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import {
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerVacayTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'vacay');
const W = canWrite(scopes, 'vacay');
if (isAddonEnabled(ADDON_IDS.VACAY)) {
if (R) server.registerTool(
'get_vacay_plan',
{
description: "Get the current user's active vacation plan (own or joined).",
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const plan = getPlanData(userId);
return ok({ plan });
}
);
if (W) server.registerTool(
'update_vacay_plan',
{
description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).',
inputSchema: {
block_weekends: z.boolean().optional(),
holidays_enabled: z.boolean().optional(),
holidays_region: z.string().nullable().optional(),
company_holidays_enabled: z.boolean().optional(),
carry_over_enabled: z.boolean().optional(),
if (R)
server.registerTool(
'get_vacay_plan',
{
description: "Get the current user's active vacation plan (own or joined).",
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
return ok({ success: true });
}
);
if (W) server.registerTool(
'set_vacay_color',
{
description: "Set the current user's color in the vacation plan calendar.",
inputSchema: {
color: z.string().describe('Hex color e.g. #6366f1'),
async () => {
const plan = getPlanData(userId);
return ok({ plan });
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ color }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
setUserColor(userId, planId, color, undefined);
return ok({ success: true });
}
);
);
if (R) server.registerTool(
'get_available_vacay_users',
{
description: 'List users who can be invited to the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const planId = getActivePlanId(userId);
const users = getAvailableUsers(userId, planId);
return ok({ users });
}
);
if (W) server.registerTool(
'send_vacay_invite',
{
description: 'Invite a user to join the vacation plan by their user ID.',
inputSchema: {
targetUserId: z.number().int().positive(),
if (W)
server.registerTool(
'update_vacay_plan',
{
description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).',
inputSchema: {
block_weekends: z.boolean().optional(),
holidays_enabled: z.boolean().optional(),
holidays_region: z.string().nullable().optional(),
company_holidays_enabled: z.boolean().optional(),
carry_over_enabled: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const me = getCurrentUser(userId);
if (!me) return { content: [{ type: 'text' as const, text: 'User not found.' }], isError: true };
const result = sendVacayInvite(planId, userId, me.username, me.email, targetUserId);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
}
);
if (W) server.registerTool(
'accept_vacay_invite',
{
description: 'Accept a pending invitation to join another user\'s vacation plan.',
inputSchema: {
planId: z.number().int().positive(),
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
await updatePlan(
planId,
{ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled },
undefined,
);
return ok({ success: true });
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ planId }) => {
if (isDemoUser(userId)) return demoDenied();
const result = acceptInvite(userId, planId, undefined);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
}
);
);
if (W) server.registerTool(
'decline_vacay_invite',
{
description: 'Decline a pending vacation plan invitation.',
inputSchema: {
planId: z.number().int().positive(),
if (W)
server.registerTool(
'set_vacay_color',
{
description: "Set the current user's color in the vacation plan calendar.",
inputSchema: {
color: z.string().describe('Hex color e.g. #6366f1'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ planId }) => {
declineInvite(userId, planId, undefined);
return ok({ success: true });
}
);
if (W) server.registerTool(
'cancel_vacay_invite',
{
description: 'Cancel an outgoing invitation (owner cancels invite they sent).',
inputSchema: {
targetUserId: z.number().int().positive(),
async ({ color }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
setUserColor(userId, planId, color, undefined);
return ok({ success: true });
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
cancelInvite(planId, targetUserId);
return ok({ success: true });
}
);
);
if (W) server.registerTool(
'dissolve_vacay_plan',
{
description: 'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async () => {
if (isDemoUser(userId)) return demoDenied();
dissolvePlan(userId, undefined);
return ok({ success: true });
}
);
if (R) server.registerTool(
'list_vacay_years',
{
description: 'List calendar years tracked in the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const planId = getActivePlanId(userId);
const years = listYears(planId);
return ok({ years });
}
);
if (W) server.registerTool(
'add_vacay_year',
{
description: 'Add a calendar year to the vacation plan.',
inputSchema: {
year: z.number().int().min(2000).max(2100),
if (R)
server.registerTool(
'get_available_vacay_users',
{
description: 'List users who can be invited to the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ year }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const years = addYear(planId, year, undefined);
return ok({ years });
}
);
if (W) server.registerTool(
'delete_vacay_year',
{
description: 'Remove a calendar year from the vacation plan.',
inputSchema: {
year: z.number().int(),
async () => {
const planId = getActivePlanId(userId);
const users = getAvailableUsers(userId, planId);
return ok({ users });
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ year }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const years = deleteYear(planId, year, undefined);
return ok({ years });
}
);
);
if (R) server.registerTool(
'get_vacay_entries',
{
description: 'Get all vacation day entries for a plan and year.',
inputSchema: {
year: z.number().int(),
if (W)
server.registerTool(
'send_vacay_invite',
{
description: 'Invite a user to join the vacation plan by their user ID.',
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
const planId = getActivePlanId(userId);
const entries = getVacayEntries(planId, String(year));
return ok({ entries });
}
);
if (W) server.registerTool(
'toggle_vacay_entry',
{
description: 'Toggle a day on or off as a vacation day for the current user.',
inputSchema: {
date: z.string().describe('ISO date YYYY-MM-DD'),
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const me = getCurrentUser(userId);
if (!me) return { content: [{ type: 'text' as const, text: 'User not found.' }], isError: true };
const result = sendVacayInvite(planId, userId, me.username, me.email, targetUserId);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ date }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const result = toggleEntry(userId, planId, date, undefined);
return ok(result);
}
);
);
if (W) server.registerTool(
'toggle_company_holiday',
{
description: 'Toggle a date as a company holiday for the whole plan.',
inputSchema: {
date: z.string(),
note: z.string().optional(),
if (W)
server.registerTool(
'accept_vacay_invite',
{
description: "Accept a pending invitation to join another user's vacation plan.",
inputSchema: {
planId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ date, note }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const result = toggleCompanyHoliday(planId, date, note, undefined);
return ok(result);
}
);
if (R) server.registerTool(
'get_vacay_stats',
{
description: 'Get vacation statistics for a specific year (days used, remaining, carried over).',
inputSchema: {
year: z.number().int(),
async ({ planId }) => {
if (isDemoUser(userId)) return demoDenied();
const result = acceptInvite(userId, planId, undefined);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
const planId = getActivePlanId(userId);
const stats = getVacayStats(planId, year);
return ok({ stats });
}
);
);
if (W) server.registerTool(
'update_vacay_stats',
{
description: 'Update the vacation day allowance for a specific user and year.',
inputSchema: {
year: z.number().int(),
vacationDays: z.number().int().min(0),
if (W)
server.registerTool(
'decline_vacay_invite',
{
description: 'Decline a pending vacation plan invitation.',
inputSchema: {
planId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ year, vacationDays }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
updateVacayStats(userId, planId, year, vacationDays, undefined);
return ok({ success: true });
}
);
if (W) server.registerTool(
'add_holiday_calendar',
{
description: 'Add a public holiday calendar (by region code) to the vacation plan.',
inputSchema: {
region: z.string().describe('Country/region code e.g. US, GB, DE'),
label: z.string().nullable().optional(),
color: z.string().optional(),
sortOrder: z.number().int().optional(),
async ({ planId }) => {
declineInvite(userId, planId, undefined);
return ok({ success: true });
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ region, label, color, sortOrder }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const calendar = addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined);
return ok({ calendar });
}
);
);
if (W) server.registerTool(
'update_holiday_calendar',
{
description: 'Update label or color for a holiday calendar.',
inputSchema: {
calendarId: z.number().int().positive(),
label: z.string().nullable().optional(),
color: z.string().optional(),
if (W)
server.registerTool(
'cancel_vacay_invite',
{
description: 'Cancel an outgoing invitation (owner cancels invite they sent).',
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ calendarId, label, color }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const cal = updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
if (!cal) return { content: [{ type: 'text' as const, text: 'Holiday calendar not found.' }], isError: true };
return ok({ calendar: cal });
}
);
if (W) server.registerTool(
'delete_holiday_calendar',
{
description: 'Remove a holiday calendar from the vacation plan.',
inputSchema: {
calendarId: z.number().int().positive(),
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
cancelInvite(planId, targetUserId);
return ok({ success: true });
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ calendarId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
deleteHolidayCalendar(calendarId, planId, undefined);
return ok({ success: true });
}
);
);
if (R) server.registerTool(
'list_holiday_countries',
{
description: 'List countries available for public holiday calendars.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const result = await getHolidayCountries();
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ countries: result.data });
}
);
if (R) server.registerTool(
'list_holidays',
{
description: 'List public holidays for a country and year.',
inputSchema: {
country: z.string().describe('ISO 3166-1 alpha-2 code'),
year: z.number().int(),
if (W)
server.registerTool(
'dissolve_vacay_plan',
{
description:
'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_DELETE,
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ country, year }) => {
const result = await getHolidays(String(year), country);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ holidays: result.data });
}
);
async () => {
if (isDemoUser(userId)) return demoDenied();
dissolvePlan(userId, undefined);
return ok({ success: true });
},
);
if (R)
server.registerTool(
'list_vacay_years',
{
description: 'List calendar years tracked in the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const planId = getActivePlanId(userId);
const years = listYears(planId);
return ok({ years });
},
);
if (W)
server.registerTool(
'add_vacay_year',
{
description: 'Add a calendar year to the vacation plan.',
inputSchema: {
year: z.number().int().min(2000).max(2100),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ year }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const years = addYear(planId, year, undefined);
return ok({ years });
},
);
if (W)
server.registerTool(
'delete_vacay_year',
{
description: 'Remove a calendar year from the vacation plan.',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ year }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const years = deleteYear(planId, year, undefined);
return ok({ years });
},
);
if (R)
server.registerTool(
'get_vacay_entries',
{
description: 'Get all vacation day entries for a plan and year.',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
const planId = getActivePlanId(userId);
const entries = getVacayEntries(planId, String(year));
return ok({ entries });
},
);
if (W)
server.registerTool(
'toggle_vacay_entry',
{
description: 'Toggle a day on or off as a vacation day for the current user.',
inputSchema: {
date: z.string().describe('ISO date YYYY-MM-DD'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ date }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const result = toggleEntry(userId, planId, date, undefined);
return ok(result);
},
);
if (W)
server.registerTool(
'toggle_company_holiday',
{
description: 'Toggle a date as a company holiday for the whole plan.',
inputSchema: {
date: z.string(),
note: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ date, note }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const result = toggleCompanyHoliday(planId, date, note, undefined);
return ok(result);
},
);
if (R)
server.registerTool(
'get_vacay_stats',
{
description: 'Get vacation statistics for a specific year (days used, remaining, carried over).',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
const planId = getActivePlanId(userId);
const stats = getVacayStats(planId, year);
return ok({ stats });
},
);
if (W)
server.registerTool(
'update_vacay_stats',
{
description: 'Update the vacation day allowance for a specific user and year.',
inputSchema: {
year: z.number().int(),
vacationDays: z.number().int().min(0),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ year, vacationDays }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
updateVacayStats(userId, planId, year, vacationDays, undefined);
return ok({ success: true });
},
);
if (W)
server.registerTool(
'add_holiday_calendar',
{
description: 'Add a public holiday calendar (by region code) to the vacation plan.',
inputSchema: {
region: z.string().describe('Country/region code e.g. US, GB, DE'),
label: z.string().nullable().optional(),
color: z.string().optional(),
sortOrder: z.number().int().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ region, label, color, sortOrder }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const calendar = addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined);
return ok({ calendar });
},
);
if (W)
server.registerTool(
'update_holiday_calendar',
{
description: 'Update label or color for a holiday calendar.',
inputSchema: {
calendarId: z.number().int().positive(),
label: z.string().nullable().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ calendarId, label, color }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const cal = updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
if (!cal) return { content: [{ type: 'text' as const, text: 'Holiday calendar not found.' }], isError: true };
return ok({ calendar: cal });
},
);
if (W)
server.registerTool(
'delete_holiday_calendar',
{
description: 'Remove a holiday calendar from the vacation plan.',
inputSchema: {
calendarId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ calendarId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
deleteHolidayCalendar(calendarId, planId, undefined);
return ok({ success: true });
},
);
if (R)
server.registerTool(
'list_holiday_countries',
{
description: 'List countries available for public holiday calendars.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const result = await getHolidayCountries();
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ countries: result.data });
},
);
if (R)
server.registerTool(
'list_holidays',
{
description: 'List public holidays for a country and year.',
inputSchema: {
country: z.string().describe('ISO 3166-1 alpha-2 code'),
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ country, year }) => {
const result = await getHolidays(String(year), country);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ holidays: result.data });
},
);
}
}
+9 -8
View File
@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { db } from '../db/database';
import { isDemoEmail } from '../services/demo';
import { AuthRequest, OptionalAuthRequest, User } from '../types';
import { applyIdempotency } from './idempotency';
import { isDemoEmail } from '../services/demo';
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export function extractToken(req: Request): string | null {
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
@@ -28,9 +29,9 @@ export function extractToken(req: Request): string | null {
export function verifyJwtAndLoadUser(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number };
const row = db.prepare(
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
).get(decoded.id) as (User & { password_version?: number }) | undefined;
const row = db
.prepare('SELECT id, username, email, role, password_version FROM users WHERE id = ?')
.get(decoded.id) as (User & { password_version?: number }) | undefined;
if (!row) return null;
// Session invalidation: any token whose embedded password_version
// predates the user's current one is rejected. Tokens issued before
@@ -41,7 +42,7 @@ export function verifyJwtAndLoadUser(token: string): User | null {
if (tokenPv !== currentPv) return null;
// Don't leak password_version beyond the middleware.
const { password_version: _pv, ...user } = row;
return user as User;
return user;
} catch {
return null;
}
+8 -5
View File
@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import { db } from '../db/database';
import { Request, Response, NextFunction } from 'express';
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
// Reject pathological client-supplied keys outright instead of hashing
// everything — 128 chars is plenty for any realistic UUID / ULID / nonce.
@@ -48,9 +49,11 @@ export function applyIdempotency(req: Request, res: Response, next: NextFunction
// Return cached response only if the same key was seen for the same
// user AND the same method+path — avoids a POST's cached body leaking
// into an unrelated PATCH that reused the idempotency-key string.
const existing = db.prepare(
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?'
).get(key, userId, req.method, req.path) as IdempotencyRow | undefined;
const existing = db
.prepare(
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?',
)
.get(key, userId, req.method, req.path) as IdempotencyRow | undefined;
if (existing) {
res.status(existing.status_code).json(JSON.parse(existing.response_body));
@@ -66,7 +69,7 @@ export function applyIdempotency(req: Request, res: Response, next: NextFunction
if (serialized.length <= MAX_CACHED_BODY_BYTES) {
db.prepare(
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(key, userId, req.method, req.path, res.statusCode, serialized, Math.floor(Date.now() / 1000));
}
} catch {
+6 -3
View File
@@ -1,7 +1,8 @@
import { Request, Response, NextFunction } from 'express';
import { db } from '../db/database';
import { extractToken, verifyJwtAndLoadUser } from './auth';
import { DEMO_EMAILS } from '../services/demo';
import { extractToken, verifyJwtAndLoadUser } from './auth';
import { Request, Response, NextFunction } from 'express';
/** Paths that never require MFA (public or pre-auth). */
export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
@@ -62,7 +63,9 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
}
const userId = verified.id;
const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as
| { value: string }
| undefined;
if (requireRow?.value !== 'true') {
next();
return;
+2 -1
View File
@@ -1,7 +1,8 @@
import { Request, Response, NextFunction } from 'express';
import { canAccessTrip, isOwner } from '../db/database';
import { AuthRequest } from '../types';
import { Request, Response, NextFunction } from 'express';
/** Middleware: verifies the authenticated user is an owner or member of the trip, then attaches trip to req. */
function requireTripAccess(req: Request, res: Response, next: NextFunction): void {
const authReq = req as AuthRequest;
+3 -3
View File
@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { TrekExceptionFilter } from './common/trek-exception.filter';
import { DatabaseModule } from './database/database.module';
import { HealthController } from './health/health.controller';
import { HealthService } from './health/health.service';
import { WeatherModule } from './weather/weather.module';
import { TrekExceptionFilter } from './common/trek-exception.filter';
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
/**
* Root NestJS module for the incremental migration. Domain modules
+3 -2
View File
@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
/**
* Mirrors the legacy `adminOnly` middleware: requires an authenticated admin.
@@ -1,12 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { User } from '../../types';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Resolves the authenticated user attached by JwtAuthGuard.
* Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`.
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): User | undefined => {
return context.switchToHttp().getRequest().user;
},
);
export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext): User | undefined => {
return context.switchToHttp().getRequest().user;
});
+3 -2
View File
@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
/**
* Validates TREK's existing JWT session the same httpOnly `trek_session`
@@ -1,4 +1,5 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { Response } from 'express';
/**
@@ -26,11 +27,7 @@ export class TrekExceptionFilter implements ExceptionFilter {
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
const message =
status < 500
? Array.isArray(raw)
? raw.join(', ')
: String(raw ?? 'Error')
: 'Internal server error';
status < 500 ? (Array.isArray(raw) ? raw.join(', ') : String(raw ?? 'Error')) : 'Internal server error';
res.status(status).json({ error: message });
return;
}
@@ -1,4 +1,5 @@
import { ArgumentMetadata, HttpException, Injectable, PipeTransform } from '@nestjs/common';
import type { ZodType } from 'zod';
/**
@@ -16,9 +17,7 @@ export class ZodValidationPipe implements PipeTransform {
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
const result = this.schema.safeParse(value);
if (!result.success) {
const message = result.error.issues
.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`)
.join('; ');
const message = result.error.issues.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`).join('; ');
throw new HttpException({ error: message }, 400);
}
return result.data;
+1 -1
View File
@@ -1,5 +1,5 @@
import { Global, Module } from '@nestjs/common';
import { DatabaseService } from './database.service';
import { Global, Module } from '@nestjs/common';
/**
* Global so every migrated module can inject DatabaseService without re-importing.
+3 -2
View File
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import type Database from 'better-sqlite3';
import { db } from '../../db/database';
import { Injectable } from '@nestjs/common';
import type Database from 'better-sqlite3';
/**
* Injectable wrapper around TREK's existing better-sqlite3 connection.
+5 -4
View File
@@ -1,10 +1,11 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import type { User } from '../../types';
import { HealthService } from './health.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { HealthService } from './health.service';
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { z } from 'zod';
// Local demo schema (real domains import their schema from @trek/shared).
const echoSchema = z.object({ name: z.string().min(1) });
+1 -1
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { Injectable } from '@nestjs/common';
/**
* Smoke service proving NestJS DI works under the chosen runtime AND that the
+4 -1
View File
@@ -13,7 +13,10 @@ const DEFAULT_NEST_PREFIXES = ['/api/_nest', '/api/weather'];
export function getNestPrefixes(): string[] {
const raw = process.env.NEST_PREFIXES;
if (raw !== undefined) {
return raw.split(',').map((s) => s.trim()).filter(Boolean);
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
return DEFAULT_NEST_PREFIXES;
}
@@ -1,8 +1,8 @@
import { ApiError } from '../../services/weatherService';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { WeatherService } from './weather.service';
import { Controller, Get, HttpException, Query, UseGuards } from '@nestjs/common';
import type { WeatherResult } from '@trek/shared';
import { WeatherService } from './weather.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiError } from '../../services/weatherService';
/**
* /api/weather first migrated leaf module (the pilot).
+1 -1
View File
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { WeatherController } from './weather.controller';
import { WeatherService } from './weather.service';
import { Module } from '@nestjs/common';
/** Weather domain (pilot leaf module). Registered in AppModule. */
@Module({
+3 -3
View File
@@ -1,6 +1,6 @@
import { getWeather, getDetailedWeather } from '../../services/weatherService';
import { Injectable } from '@nestjs/common';
import type { WeatherResult } from '@trek/shared';
import { getWeather, getDetailedWeather } from '../../services/weatherService';
/**
* Thin Nest wrapper around the existing weather service. It delegates to the
@@ -12,10 +12,10 @@ import { getWeather, getDetailedWeather } from '../../services/weatherService';
@Injectable()
export class WeatherService {
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
return getWeather(lat, lng, date, lang);
}
getDetailed(lat: string, lng: string, date: string, lang: string): Promise<WeatherResult> {
return getDetailedWeather(lat, lng, date, lang) as Promise<WeatherResult>;
return getDetailedWeather(lat, lng, date, lang);
}
}
+32 -27
View File
@@ -1,11 +1,12 @@
import express, { Request, Response } from 'express';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import * as svc from '../services/adminService';
import { getAdminUserDefaults, setAdminUserDefaults } from '../services/settingsService';
import { invalidateMcpSessions } from '../mcp';
import { authenticate, adminOnly } from '../middleware/auth';
import * as svc from '../services/adminService';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
import { getAdminUserDefaults, setAdminUserDefaults } from '../services/settingsService';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
@@ -19,7 +20,7 @@ router.get('/users', (_req: Request, res: Response) => {
router.post('/users', (req: Request, res: Response) => {
const result = svc.createUser(req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
@@ -33,7 +34,7 @@ router.post('/users', (req: Request, res: Response) => {
router.put('/users/:id', (req: Request, res: Response) => {
const result = svc.updateUser(req.params.id, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
@@ -49,7 +50,7 @@ router.put('/users/:id', (req: Request, res: Response) => {
router.delete('/users/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.deleteUser(req.params.id, authReq.user.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
writeAudit({
userId: authReq.user.id,
action: 'admin.user_delete',
@@ -87,13 +88,17 @@ router.put('/permissions', (req: Request, res: Response) => {
ip: getClientIp(req),
details: permissions,
});
res.json({ success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) });
res.json({
success: true,
permissions: result.permissions,
...(result.skipped.length ? { skipped: result.skipped } : {}),
});
});
// ── Audit Log ──────────────────────────────────────────────────────────────
router.get('/audit-log', (req: Request, res: Response) => {
res.json(svc.getAuditLog(req.query as { limit?: string; offset?: string }));
res.json(svc.getAuditLog(req.query));
});
// ── OIDC Settings ──────────────────────────────────────────────────────────
@@ -121,7 +126,7 @@ router.put('/oidc', (req: Request, res: Response) => {
router.post('/save-demo-baseline', (req: Request, res: Response) => {
const result = svc.saveDemoBaseline();
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
res.json({ success: true, message: result.message });
@@ -172,7 +177,7 @@ router.post('/invites', (req: Request, res: Response) => {
router.delete('/invites/:id', (req: Request, res: Response) => {
const result = svc.deleteInvite(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
@@ -285,26 +290,26 @@ router.get('/packing-templates', (_req: Request, res: Response) => {
router.get('/packing-templates/:id', (req: Request, res: Response) => {
const result = svc.getPackingTemplate(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json(result);
});
router.post('/packing-templates', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.createPackingTemplate(req.body.name, authReq.user.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.status(201).json(result);
});
router.put('/packing-templates/:id', (req: Request, res: Response) => {
const result = svc.updatePackingTemplate(req.params.id, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json(result);
});
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
const result = svc.deletePackingTemplate(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
@@ -320,19 +325,19 @@ router.delete('/packing-templates/:id', (req: Request, res: Response) => {
router.post('/packing-templates/:id/categories', (req: Request, res: Response) => {
const result = svc.createTemplateCategory(req.params.id, req.body.name);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.status(201).json(result);
});
router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
const result = svc.updateTemplateCategory(req.params.templateId, req.params.catId, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json(result);
});
router.delete('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
const result = svc.deleteTemplateCategory(req.params.templateId, req.params.catId);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json({ success: true });
});
@@ -340,19 +345,19 @@ router.delete('/packing-templates/:templateId/categories/:catId', (req: Request,
router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => {
const result = svc.createTemplateItem(req.params.templateId, req.params.catId, req.body.name);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.status(201).json(result);
});
router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
const result = svc.updateTemplateItem(req.params.itemId, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json(result);
});
router.delete('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
const result = svc.deleteTemplateItem(req.params.itemId);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json({ success: true });
});
@@ -364,7 +369,7 @@ router.get('/addons', (_req: Request, res: Response) => {
router.put('/addons/:id', (req: Request, res: Response) => {
const result = svc.updateAddon(req.params.id, req.body);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
@@ -386,7 +391,7 @@ router.get('/mcp-tokens', (_req: Request, res: Response) => {
router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
const result = svc.deleteMcpToken(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json({ success: true });
});
@@ -398,7 +403,7 @@ router.get('/oauth-sessions', (_req: Request, res: Response) => {
router.delete('/oauth-sessions/:id', (req: Request, res: Response) => {
const result = svc.revokeOAuthSession(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
if ('error' in result) return res.status(result.status).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
@@ -413,7 +418,7 @@ router.delete('/oauth-sessions/:id', (req: Request, res: Response) => {
router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
const result = svc.rotateJwtSecret();
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
+2 -1
View File
@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { searchAirports, findByIata } from '../services/airportService';
import express, { Request, Response } from 'express';
const router = express.Router();
router.get('/search', authenticate, (req: Request, res: Response) => {
+152 -56
View File
@@ -1,8 +1,5 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
import {
getAssignmentWithPlace,
listDayAssignments,
@@ -19,7 +16,11 @@ import {
setParticipants,
} from '../services/assignmentService';
import { onPlaceCreated } from '../services/journeyService';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
const router = express.Router({ mergeParams: true });
@@ -32,55 +33,114 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAc
res.json({ assignments: result });
});
router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
router.post(
'/trips/:tripId/days/:dayId/assignments',
authenticate,
requireTripAccess,
(req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, dayId } = req.params;
const { place_id, notes } = req.body;
const { tripId, dayId } = req.params;
const { place_id, notes } = req.body;
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
if (!placeExists(place_id, tripId)) return res.status(404).json({ error: 'Place not found' });
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
if (!placeExists(place_id, tripId)) return res.status(404).json({ error: 'Place not found' });
const assignment = createAssignment(dayId, place_id, notes);
res.status(201).json({ assignment });
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id'] as string);
try { onPlaceCreated(Number(tripId), Number(place_id)); } catch {}
});
const assignment = createAssignment(dayId, place_id, notes);
res.status(201).json({ assignment });
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id'] as string);
try {
onPlaceCreated(Number(tripId), Number(place_id));
} catch {}
},
);
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
router.delete(
'/trips/:tripId/days/:dayId/assignments/:id',
authenticate,
requireTripAccess,
(req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, dayId, id } = req.params;
const { tripId, dayId, id } = req.params;
if (!assignmentExistsInDay(id, dayId, tripId)) return res.status(404).json({ error: 'Assignment not found' });
if (!assignmentExistsInDay(id, dayId, tripId)) return res.status(404).json({ error: 'Assignment not found' });
deleteAssignment(id);
res.json({ success: true });
broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string);
});
deleteAssignment(id);
res.json({ success: true });
broadcast(
tripId,
'assignment:deleted',
{ assignmentId: Number(id), dayId: Number(dayId) },
req.headers['x-socket-id'] as string,
);
},
);
router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
router.put(
'/trips/:tripId/days/:dayId/assignments/reorder',
authenticate,
requireTripAccess,
(req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, dayId } = req.params;
const { orderedIds } = req.body;
const { tripId, dayId } = req.params;
const { orderedIds } = req.body;
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
reorderAssignments(dayId, orderedIds);
res.json({ success: true });
broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id'] as string);
});
reorderAssignments(dayId, orderedIds);
res.json({ success: true });
broadcast(
tripId,
'assignment:reordered',
{ dayId: Number(dayId), orderedIds },
req.headers['x-socket-id'] as string,
);
},
);
router.put('/trips/:tripId/assignments/:id/move', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
@@ -94,19 +154,37 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, requireTripAcces
const oldDayId = existing.day_id;
const { assignment: updated } = moveAssignment(id, new_day_id, order_index, oldDayId);
res.json({ assignment: updated });
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id'] as string);
broadcast(
tripId,
'assignment:moved',
{ assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) },
req.headers['x-socket-id'] as string,
);
});
router.get('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { id } = req.params;
router.get(
'/trips/:tripId/assignments/:id/participants',
authenticate,
requireTripAccess,
(req: Request, res: Response) => {
const { id } = req.params;
const participants = getParticipants(id);
res.json({ participants });
});
const participants = getParticipants(id);
res.json({ participants });
},
);
router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
@@ -120,18 +198,36 @@ router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAcces
broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id'] as string);
});
router.put('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
router.put(
'/trips/:tripId/assignments/:id/participants',
authenticate,
requireTripAccess,
(req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
const { user_ids } = req.body;
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
const { tripId, id } = req.params;
const { user_ids } = req.body;
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
const participants = setParticipants(id, user_ids);
res.json({ participants });
broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id'] as string);
});
const participants = setParticipants(id, user_ids);
res.json({ participants });
broadcast(
Number(tripId),
'assignment:participants',
{ assignmentId: Number(id), participants },
req.headers['x-socket-id'] as string,
);
},
);
export default router;
+4 -3
View File
@@ -1,6 +1,4 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import {
getStats,
getCountryPlaces,
@@ -15,6 +13,9 @@ import {
updateBucketItem,
deleteBucketItem,
} from '../services/atlasService';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
router.use(authenticate);
@@ -33,7 +34,7 @@ router.get('/regions', async (req: Request, res: Response) => {
});
router.get('/regions/geo', async (req: Request, res: Response) => {
const countries = (req.query.countries as string || '').split(',').filter(Boolean);
const countries = ((req.query.countries as string) || '').split(',').filter(Boolean);
if (countries.length === 0) return res.json({ type: 'FeatureCollection', features: [] });
const geo = await getRegionGeo(countries);
res.setHeader('Cache-Control', 'public, max-age=86400');
+86 -44
View File
@@ -1,12 +1,5 @@
import express, { Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuid } from 'uuid';
import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { setAuthCookie, clearAuthCookie } from '../services/cookie';
import {
getAppConfig,
demoLogin,
@@ -39,7 +32,15 @@ import {
requestPasswordReset,
resetPassword,
} from '../services/authService';
import { setAuthCookie, clearAuthCookie } from '../services/cookie';
import { sendPasswordResetEmail, getAppUrl } from '../services/notifications';
import { AuthRequest, OptionalAuthRequest } from '../types';
import express, { Request, Response, NextFunction } from 'express';
import fs from 'fs';
import multer from 'multer';
import path from 'path';
import { v4 as uuid } from 'uuid';
const router = express.Router();
@@ -129,22 +130,32 @@ router.get('/app-config', optionalAuth, (req: Request, res: Response) => {
router.post('/demo-login', (req: Request, res: Response) => {
const result = demoLogin();
if (result.error) return res.status(result.status!).json({ error: result.error });
setAuthCookie(res, result.token!, req);
if (result.error) return res.status(result.status).json({ error: result.error });
setAuthCookie(res, result.token, req);
res.json({ token: result.token, user: result.user });
});
router.get('/invite/:token', authLimiter, (req: Request, res: Response) => {
const result = validateInviteToken(req.params.token);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({
valid: result.valid,
max_uses: result.max_uses,
used_count: result.used_count,
expires_at: result.expires_at,
});
});
router.post('/register', authLimiter, (req: Request, res: Response) => {
const result = registerUser(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
setAuthCookie(res, result.token!, req);
if (result.error) return res.status(result.status).json({ error: result.error });
writeAudit({
userId: result.auditUserId,
action: 'user.register',
ip: getClientIp(req),
details: result.auditDetails,
});
setAuthCookie(res, result.token, req);
res.status(201).json({ token: result.token, user: result.user });
});
@@ -152,15 +163,20 @@ router.post('/login', authLimiter, async (req: Request, res: Response) => {
const started = Date.now();
const result = loginUser(req.body);
if (result.auditAction) {
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
writeAudit({
userId: result.auditUserId ?? null,
action: result.auditAction,
ip: getClientIp(req),
details: result.auditDetails,
});
}
const elapsed = Date.now() - started;
if (elapsed < LOGIN_MIN_LATENCY_MS) {
await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed));
}
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
setAuthCookie(res, result.token!, req);
setAuthCookie(res, result.token, req);
res.json({ token: result.token, user: result.user });
});
@@ -193,17 +209,37 @@ router.post('/forgot-password', forgotLimiter, async (req: Request, res: Respons
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
// Audit the REQUEST always — even for "no user" — so abuse is visible.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
writeAudit({
userId: outcome.userId,
action: 'user.password_reset_request',
ip,
details: { delivered: 'pending' },
});
try {
const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
writeAudit({
userId: outcome.userId,
action: 'user.password_reset_request',
ip,
details: { delivered: delivery.delivered },
});
} catch (err) {
// Never surface delivery failure to the caller — still respond ok.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
writeAudit({
userId: outcome.userId,
action: 'user.password_reset_request',
ip,
details: { delivered: 'failed' },
});
}
} else {
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
writeAudit({
userId: outcome.userId,
action: 'user.password_reset_request',
ip,
details: { reason: outcome.reason },
});
}
// Pad the response so timing doesn't reveal outcome.
@@ -219,7 +255,7 @@ router.post('/reset-password', resetLimiter, (req: Request, res: Response) => {
const result = resetPassword(req.body);
if (result.error) {
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
return res.status(result.status!).json({ error: result.error });
return res.status(result.status).json({ error: result.error });
}
if (result.mfa_required) {
return res.status(200).json({ mfa_required: true });
@@ -246,7 +282,7 @@ router.post('/logout', (req: Request, res: Response) => {
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = changePassword(authReq.user.id, authReq.user.email, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) });
res.json({ success: true });
});
@@ -254,7 +290,7 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req
router.delete('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = deleteAccount(authReq.user.id, authReq.user.email, authReq.user.role);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) });
res.json({ success: true });
});
@@ -272,22 +308,28 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = updateSettings(authReq.user.id, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ success: result.success, user: result.user });
});
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = getSettings(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ settings: result.settings });
});
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
res.json(await saveAvatar(authReq.user.id, req.file.filename));
});
router.post(
'/avatar',
authenticate,
demoUploadBlock,
avatarUpload.single('avatar'),
async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
res.json(await saveAvatar(authReq.user.id, req.file.filename));
},
);
router.delete('/avatar', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
@@ -302,21 +344,21 @@ router.get('/users', authenticate, (req: Request, res: Response) => {
router.get('/validate-keys', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await validateKeys(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ maps: result.maps, weather: result.weather, maps_details: result.maps_details });
});
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = getAppSettings(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json(result.data);
});
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = updateAppSettings(authReq.user.id, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
writeAudit({
userId: authReq.user.id,
action: 'settings.app_update',
@@ -334,17 +376,17 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => {
const result = verifyMfaLogin(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
setAuthCookie(res, result.token!, req);
if (result.error) return res.status(result.status).json({ error: result.error });
writeAudit({ userId: result.auditUserId, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
setAuthCookie(res, result.token, req);
res.json({ token: result.token, user: result.user });
});
router.post('/mfa/setup', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = setupMfa(authReq.user.id, authReq.user.email);
if (result.error) return res.status(result.status!).json({ error: result.error });
result.qrPromise!
if (result.error) return res.status(result.status).json({ error: result.error });
result.qrPromise
.then((qr_svg: string) => {
res.json({ secret: result.secret, otpauth_url: result.otpauth_url, qr_svg });
})
@@ -357,7 +399,7 @@ router.post('/mfa/setup', authenticate, (req: Request, res: Response) => {
router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = enableMfa(authReq.user.id, req.body.code);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes });
});
@@ -365,7 +407,7 @@ router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Respons
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = disableMfa(authReq.user.id, authReq.user.email, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: result.mfa_enabled });
});
@@ -380,14 +422,14 @@ router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => {
router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = createMcpToken(authReq.user.id, req.body.name);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.status(201).json({ token: result.token });
});
router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = deleteMcpToken(authReq.user.id, req.params.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ success: true });
});
@@ -395,7 +437,7 @@ router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) =>
router.post('/ws-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = createWsToken(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ token: result.token });
});
+5 -4
View File
@@ -1,8 +1,4 @@
import express, { Request, Response, NextFunction } from 'express';
import multer from 'multer';
import fs from 'fs';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import {
listBackups,
@@ -19,6 +15,11 @@ import {
BACKUP_RATE_WINDOW,
MAX_BACKUP_UPLOAD_SIZE,
} from '../services/backupService';
import { AuthRequest } from '../types';
import express, { Request, Response, NextFunction } from 'express';
import fs from 'fs';
import multer from 'multer';
const router = express.Router();
+57 -21
View File
@@ -1,9 +1,5 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import {
verifyTripAccess,
listBudgetItems,
@@ -17,6 +13,11 @@ import {
reorderBudgetItems,
reorderBudgetCategories,
} from '../services/budgetService';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
const router = express.Router({ mergeParams: true });
@@ -34,8 +35,7 @@ router.get('/summary/per-person', authenticate, (req: Request, res: Response) =>
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!verifyTripAccess(Number(tripId), authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
res.json({ summary: getPerPersonSummary(tripId) });
});
@@ -47,7 +47,9 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const { name } = req.body;
@@ -66,7 +68,9 @@ router.put('/reorder/items', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
reorderBudgetItems(tripId, orderedIds);
@@ -82,7 +86,9 @@ router.put('/reorder/categories', authenticate, (req: Request, res: Response) =>
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
reorderBudgetCategories(tripId, orderedCategories);
@@ -97,7 +103,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const updated = updateBudgetItem(id, tripId, req.body);
@@ -106,7 +114,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
// Sync price back to linked reservation
if (updated.reservation_id && req.body.total_price !== undefined) {
try {
const reservation = db.prepare('SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?').get(updated.reservation_id, tripId) as { id: number; metadata: string | null } | undefined;
const reservation = db
.prepare('SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?')
.get(updated.reservation_id, tripId) as { id: number; metadata: string | null } | undefined;
if (reservation) {
const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
meta.price = String(updated.total_price);
@@ -130,7 +140,15 @@ router.put('/:id/members', authenticate, (req: Request, res: Response) => {
const access = verifyTripAccess(Number(tripId), authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'budget_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { user_ids } = req.body;
@@ -140,7 +158,12 @@ router.put('/:id/members', authenticate, (req: Request, res: Response) => {
if (!result) return res.status(404).json({ error: 'Budget item not found' });
res.json({ members: result.members, item: result.item });
broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members: result.members, persons: result.item.persons }, req.headers['x-socket-id'] as string);
broadcast(
Number(tripId),
'budget:members-updated',
{ itemId: Number(id), members: result.members, persons: result.item.persons },
req.headers['x-socket-id'] as string,
);
});
router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Response) => {
@@ -150,21 +173,33 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
const access = verifyTripAccess(Number(tripId), authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'budget_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { paid } = req.body;
const member = toggleMemberPaid(id, userId, paid);
res.json({ member });
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
broadcast(
Number(tripId),
'budget:member-paid-updated',
{ itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 },
req.headers['x-socket-id'] as string,
);
});
router.get('/settlement', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!verifyTripAccess(Number(tripId), authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
res.json(calculateSettlement(tripId));
});
@@ -176,11 +211,12 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!deleteBudgetItem(id, tripId))
return res.status(404).json({ error: 'Budget item not found' });
if (!deleteBudgetItem(id, tripId)) return res.status(404).json({ error: 'Budget item not found' });
res.json({ success: true });
broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
+5 -6
View File
@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import * as categoryService from '../services/categoryService';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
@@ -19,15 +20,13 @@ router.post('/', authenticate, adminOnly, (req: Request, res: Response) => {
router.put('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
const { name, color, icon } = req.body;
if (!categoryService.getCategoryById(req.params.id))
return res.status(404).json({ error: 'Category not found' });
if (!categoryService.getCategoryById(req.params.id)) return res.status(404).json({ error: 'Category not found' });
const category = categoryService.updateCategory(req.params.id, name, color, icon);
res.json({ category });
});
router.delete('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
if (!categoryService.getCategoryById(req.params.id))
return res.status(404).json({ error: 'Category not found' });
if (!categoryService.getCategoryById(req.params.id)) return res.status(404).json({ error: 'Category not found' });
categoryService.deleteCategory(req.params.id);
res.json({ success: true });
});
+171 -32
View File
@@ -1,15 +1,6 @@
import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { db } from '../db/database';
import { BLOCKED_EXTENSIONS } from '../services/fileService';
import { authenticate } from '../middleware/auth';
import { validateStringLengths } from '../middleware/validate';
import {
verifyTripAccess,
listNotes,
@@ -30,13 +21,28 @@ import {
addOrRemoveReaction,
fetchLinkPreview,
} from '../services/collabService';
import { BLOCKED_EXTENSIONS } from '../services/fileService';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
import fs from 'fs';
import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
const filesDir = path.join(__dirname, '../../uploads/files');
const noteUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir) },
filename: (_req, file, cb) => { cb(null, `${uuidv4()}${path.extname(file.originalname)}`) },
destination: (_req, _file, cb) => {
if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true });
cb(null, filesDir);
},
filename: (_req, file, cb) => {
cb(null, `${uuidv4()}${path.extname(file.originalname)}`);
},
}),
limits: { fileSize: MAX_NOTE_FILE_SIZE },
defParamCharset: 'utf8',
@@ -45,7 +51,12 @@ const noteUpload = multer({
// Share the single BLOCKED_EXTENSIONS list from fileService so
// executable/script attachments can't sneak in via collab when the
// main uploader already rejects them.
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
if (
BLOCKED_EXTENSIONS.includes(ext) ||
file.mimetype.includes('svg') ||
file.mimetype.includes('html') ||
file.mimetype.includes('javascript')
) {
const err: Error & { statusCode?: number } = new Error('File type not allowed');
err.statusCode = 400;
return cb(err);
@@ -74,7 +85,15 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
const { title, content, category, color, website } = req.body;
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
if (!title) return res.status(400).json({ error: 'Title is required' });
@@ -84,7 +103,13 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
import('../services/notificationService').then(({ send }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, tripId: String(tripId) } }).catch(() => {});
send({
event: 'collab_message',
actorId: authReq.user.id,
scope: 'trip',
targetId: Number(tripId),
params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, tripId: String(tripId) },
}).catch(() => {});
});
});
@@ -94,7 +119,15 @@ router.put('/notes/:id', authenticate, (req: Request, res: Response) => {
const { title, content, category, color, pinned, website } = req.body;
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const formatted = updateNote(tripId, id, { title, content, category, color, pinned, website });
@@ -109,7 +142,15 @@ router.delete('/notes/:id', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
if (!deleteNote(tripId, id)) return res.status(404).json({ error: 'Note not found' });
@@ -127,7 +168,15 @@ router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: R
const { tripId, id } = req.params;
const access = verifyTripAccess(Number(tripId), authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('file_upload', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'file_upload',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission to upload files' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
@@ -135,7 +184,12 @@ router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: R
if (!result) return res.status(404).json({ error: 'Note not found' });
res.status(201).json(result);
broadcast(Number(tripId), 'collab:note:updated', { note: getFormattedNoteById(id) }, req.headers['x-socket-id'] as string);
broadcast(
Number(tripId),
'collab:note:updated',
{ note: getFormattedNoteById(id) },
req.headers['x-socket-id'] as string,
);
});
router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Response) => {
@@ -143,13 +197,26 @@ router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Resp
const { tripId, id, fileId } = req.params;
const access = verifyTripAccess(Number(tripId), authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
if (!deleteNoteFile(id, fileId)) return res.status(404).json({ error: 'File not found' });
res.json({ success: true });
broadcast(Number(tripId), 'collab:note:updated', { note: getFormattedNoteById(id) }, req.headers['x-socket-id'] as string);
broadcast(
Number(tripId),
'collab:note:updated',
{ note: getFormattedNoteById(id) },
req.headers['x-socket-id'] as string,
);
});
/* ------------------------------------------------------------------ */
@@ -170,7 +237,15 @@ router.post('/polls', authenticate, (req: Request, res: Response) => {
const { question, options, multiple, multiple_choice, deadline } = req.body;
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
if (!question) return res.status(400).json({ error: 'Question is required' });
if (!Array.isArray(options) || options.length < 2) {
@@ -188,7 +263,15 @@ router.post('/polls/:id/vote', authenticate, (req: Request, res: Response) => {
const { option_index } = req.body;
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const result = votePoll(tripId, id, authReq.user.id, option_index);
@@ -205,7 +288,15 @@ router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const updatedPoll = closePoll(tripId, id);
@@ -220,7 +311,15 @@ router.delete('/polls/:id', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
if (!deletePoll(tripId, id)) return res.status(404).json({ error: 'Poll not found' });
@@ -248,7 +347,15 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
const { text, reply_to } = req.body;
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
@@ -262,7 +369,13 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
import('../services/notificationService').then(({ send }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview, tripId: String(tripId) } }).catch(() => {});
send({
event: 'collab_message',
actorId: authReq.user.id,
scope: 'trip',
targetId: Number(tripId),
params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview, tripId: String(tripId) },
}).catch(() => {});
});
});
@@ -276,7 +389,15 @@ router.post('/messages/:id/react', authenticate, (req: Request, res: Response) =
const { emoji } = req.body;
const access = verifyTripAccess(Number(tripId), authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
@@ -284,7 +405,12 @@ router.post('/messages/:id/react', authenticate, (req: Request, res: Response) =
if (!result.found) return res.status(404).json({ error: 'Message not found' });
res.json({ reactions: result.reactions });
broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions: result.reactions }, req.headers['x-socket-id'] as string);
broadcast(
Number(tripId),
'collab:message:reacted',
{ messageId: Number(id), reactions: result.reactions },
req.headers['x-socket-id'] as string,
);
});
/* ------------------------------------------------------------------ */
@@ -296,7 +422,15 @@ router.delete('/messages/:id', authenticate, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'collab_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const result = deleteMessage(tripId, id, authReq.user.id);
@@ -304,7 +438,12 @@ router.delete('/messages/:id', authenticate, (req: Request, res: Response) => {
if (result.error === 'not_owner') return res.status(403).json({ error: 'You can only delete your own messages' });
res.json({ success: true });
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: result.username || authReq.user.username }, req.headers['x-socket-id'] as string);
broadcast(
tripId,
'collab:message:deleted',
{ messageId: Number(id), username: result.username || authReq.user.username },
req.headers['x-socket-id'] as string,
);
});
/* ------------------------------------------------------------------ */
+21 -8
View File
@@ -1,17 +1,19 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
import * as dayNoteService from '../services/dayNoteService';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import * as dayNoteService from '../services/dayNoteService';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
const router = express.Router({ mergeParams: true });
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, dayId } = req.params;
if (!dayNoteService.verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!dayNoteService.verifyTripAccess(tripId, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
res.json({ notes: dayNoteService.listNotes(dayId, tripId) });
});
@@ -20,7 +22,9 @@ router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }),
const { tripId, dayId } = req.params;
const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!dayNoteService.dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
@@ -37,7 +41,9 @@ router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 })
const { tripId, dayId, id } = req.params;
const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const current = dayNoteService.getNote(id, dayId, tripId);
@@ -54,13 +60,20 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const { tripId, dayId, id } = req.params;
const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!dayNoteService.getNote(id, dayId, tripId)) return res.status(404).json({ error: 'Note not found' });
dayNoteService.deleteNote(id);
res.json({ success: true });
broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string);
broadcast(
tripId,
'dayNote:deleted',
{ noteId: Number(id), dayId: Number(dayId) },
req.headers['x-socket-id'] as string,
);
});
export default router;
+84 -12
View File
@@ -1,10 +1,11 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import * as dayService from '../services/dayService';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import * as dayService from '../services/dayService';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
const router = express.Router({ mergeParams: true });
@@ -15,7 +16,15 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
router.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
@@ -28,7 +37,15 @@ router.post('/', authenticate, requireTripAccess, (req: Request, res: Response)
router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
@@ -44,7 +61,15 @@ router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
@@ -69,7 +94,15 @@ accommodationsRouter.get('/', authenticate, requireTripAccess, (req: Request, re
accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
@@ -82,7 +115,16 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
const accommodation = dayService.createAccommodation(tripId, {
place_id,
start_day_id,
end_day_id,
check_in,
check_in_end,
check_out,
confirmation,
notes,
});
res.status(201).json({ accommodation });
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string);
@@ -90,7 +132,15 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
@@ -103,14 +153,31 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request,
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
const accommodation = dayService.updateAccommodation(id, existing, {
place_id,
start_day_id,
end_day_id,
check_in,
check_in_end,
check_out,
confirmation,
notes,
});
res.json({ accommodation });
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string);
});
accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'day_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
@@ -119,7 +186,12 @@ accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Reque
const { linkedReservationId, deletedBudgetItemId } = dayService.deleteAccommodation(id);
if (linkedReservationId) {
broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }, req.headers['x-socket-id'] as string);
broadcast(
tripId,
'reservation:deleted',
{ reservationId: linkedReservationId },
req.headers['x-socket-id'] as string,
);
}
if (deletedBudgetItemId) {
broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string);
+60 -26
View File
@@ -1,13 +1,5 @@
import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { AuthRequest } from '../types';
import { checkPermission } from '../services/permissions';
import {
MAX_FILE_SIZE,
BLOCKED_EXTENSIONS,
@@ -32,6 +24,15 @@ import {
deleteFileLink,
getFileLinks,
} from '../services/fileService';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
import fs from 'fs';
import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router({ mergeParams: true });
@@ -61,7 +62,9 @@ const upload = multer({
err.statusCode = 400;
return cb(err);
}
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
const allowed = getAllowedExtensions()
.split(',')
.map((e) => e.trim().toLowerCase());
const fileExt = ext.replace('.', '');
if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) {
cb(null, true);
@@ -117,20 +120,29 @@ router.get('/', authenticate, (req: Request, res: Response) => {
});
// Upload file
router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { user_id: tripOwnerId } = authReq.trip!;
if (!checkPermission('file_upload', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
return res.status(403).json({ error: 'No permission to upload files' });
router.post(
'/',
authenticate,
requireTripAccess,
demoUploadBlock,
upload.single('file'),
(req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { user_id: tripOwnerId } = authReq.trip;
if (
!checkPermission('file_upload', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission to upload files' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const { place_id, description, reservation_id } = req.body;
const created = createFile(tripId, req.file, authReq.user.id, { place_id, description, reservation_id });
res.status(201).json({ file: created });
broadcast(tripId, 'file:created', { file: created }, req.headers['x-socket-id'] as string);
});
const { place_id, description, reservation_id } = req.body;
const created = createFile(tripId, req.file, authReq.user.id, { place_id, description, reservation_id });
res.status(201).json({ file: created });
broadcast(tripId, 'file:created', { file: created }, req.headers['x-socket-id'] as string);
},
);
// Update file metadata
router.put('/:id', authenticate, (req: Request, res: Response) => {
@@ -140,7 +152,15 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('file_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'file_edit',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission to edit files' });
const file = getFileById(id, tripId);
@@ -176,7 +196,15 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const access = verifyTripAccess(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('file_delete', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'file_delete',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission to delete files' });
const file = getFileById(id, tripId);
@@ -194,7 +222,9 @@ router.post('/:id/restore', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const file = getDeletedFile(id, tripId);
@@ -212,7 +242,9 @@ router.delete('/:id/permanent', authenticate, async (req: Request, res: Response
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const file = getDeletedFile(id, tripId);
@@ -230,7 +262,9 @@ router.delete('/trash/empty', authenticate, async (req: Request, res: Response)
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const deleted = await emptyTrash(tripId);
+73 -28
View File
@@ -1,15 +1,21 @@
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { getAllowedExtensions } from '../services/fileService';
import * as svc from '../services/journeyService';
import {
createOrUpdateJourneyShareLink,
getJourneyShareLink,
deleteJourneyShareLink,
getPublicJourney,
} from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import * as svc from '../services/journeyService';
import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService';
import { getAllowedExtensions } from '../services/fileService';
import fs from 'node:fs';
import path from 'node:path';
const router = express.Router();
@@ -33,7 +39,9 @@ const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
return cb(err);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
const allowed = getAllowedExtensions()
.split(',')
.map((e) => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
@@ -83,7 +91,12 @@ router.get('/available-trips', authenticate, (req: Request, res: Response) => {
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {}, req.headers['x-socket-id'] as string);
const result = svc.updateEntry(
Number(req.params.entryId),
authReq.user.id,
req.body || {},
req.headers['x-socket-id'] as string,
);
if (!result) return res.status(404).json({ error: 'Entry not found' });
res.json(result);
});
@@ -106,18 +119,14 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos'), as
const results: any[] = [];
for (const file of files) {
const relativePath = `journey/${file.filename}`;
const photo = svc.addPhoto(
Number(req.params.entryId),
authReq.user.id,
relativePath,
undefined,
req.body?.caption
);
const photo = svc.addPhoto(Number(req.params.entryId), authReq.user.id, relativePath, undefined, req.body?.caption);
if (photo) {
// Mirror to Immich only when the user has explicitly opted in via the
// Immich integration settings. Avoids the "surprise upload" in #730
// where a write-capable API key implicitly enabled mirroring.
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(authReq.user.id) as { immich_auto_upload?: number } | undefined;
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(authReq.user.id) as
| { immich_auto_upload?: number }
| undefined;
if (prefs?.immich_auto_upload) {
try {
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
@@ -147,7 +156,14 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
const photo = svc.addProviderPhoto(
Number(req.params.entryId),
authReq.user.id,
provider,
String(id),
caption,
pp,
);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
@@ -193,7 +209,9 @@ router.delete('/photos/:photoId', authenticate, async (req: Request, res: Respon
if (!photo) return res.status(404).json({ error: 'Photo not found' });
if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {}
try {
fs.unlinkSync(fullPath);
} catch {}
}
res.json({ success: true });
});
@@ -206,7 +224,7 @@ router.post('/:id/gallery/photos', authenticate, upload.array('photos'), async (
const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
const filePaths = files.map(f => ({ path: `journey/${f.filename}` }));
const filePaths = files.map((f) => ({ path: `journey/${f.filename}` }));
const photos = svc.uploadGalleryPhotos(Number(req.params.id), authReq.user.id, filePaths);
if (!photos.length) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json({ photos });
@@ -221,14 +239,28 @@ router.post('/:id/gallery/provider-photos', authenticate, (req: Request, res: Re
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, String(id), undefined, pp);
const photo = svc.addProviderPhotoToGallery(
Number(req.params.id),
authReq.user.id,
provider,
String(id),
undefined,
pp,
);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
}
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, asset_id, undefined, pp);
const photo = svc.addProviderPhotoToGallery(
Number(req.params.id),
authReq.user.id,
provider,
asset_id,
undefined,
pp,
);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
@@ -240,7 +272,9 @@ router.delete('/:id/gallery/:journeyPhotoId', authenticate, async (req: Request,
if (!photo) return res.status(404).json({ error: 'Photo not found or not allowed' });
if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {}
try {
fs.unlinkSync(fullPath);
} catch {}
}
res.status(204).end();
});
@@ -319,10 +353,17 @@ router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
router.put('/:id/entries/reorder', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const orderedIds = (req.body || {}).orderedIds;
if (!Array.isArray(orderedIds) || !orderedIds.every(id => Number.isFinite(Number(id)))) {
if (!Array.isArray(orderedIds) || !orderedIds.every((id) => Number.isFinite(Number(id)))) {
return res.status(400).json({ error: 'orderedIds must be an array of numbers' });
}
if (!svc.reorderEntries(Number(req.params.id), authReq.user.id, orderedIds.map(Number), req.headers['x-socket-id'] as string)) {
if (
!svc.reorderEntries(
Number(req.params.id),
authReq.user.id,
orderedIds.map(Number),
req.headers['x-socket-id'] as string,
)
) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
@@ -377,7 +418,11 @@ router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { share_timeline, share_gallery, share_map } = req.body || {};
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, {
share_timeline,
share_gallery,
share_map,
});
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.json(result);
});
+23 -6
View File
@@ -1,9 +1,14 @@
import express, { Request, Response } from 'express';
import { getPublicJourney, validateShareTokenForAsset, validateShareTokenForPhoto } from '../services/journeyShareService';
import { streamPhoto } from '../services/memories/photoResolverService';
import {
getPublicJourney,
validateShareTokenForAsset,
validateShareTokenForPhoto,
} from '../services/journeyShareService';
import { streamImmichAsset } from '../services/memories/immichService';
import path from 'node:path';
import { streamPhoto } from '../services/memories/photoResolverService';
import express, { Request, Response } from 'express';
import fs from 'node:fs';
import path from 'node:path';
const router = express.Router();
@@ -42,11 +47,23 @@ router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Reques
const effectiveOwnerId = valid.ownerId || Number(ownerId);
if (provider === 'immich') {
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
await streamImmichAsset(
res,
effectiveOwnerId,
assetId,
kind === 'thumbnail' ? 'thumbnail' : 'original',
effectiveOwnerId,
);
} else {
try {
const { streamSynologyAsset } = await import('../services/memories/synologyService');
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
await streamSynologyAsset(
res,
effectiveOwnerId,
effectiveOwnerId,
assetId,
kind === 'thumbnail' ? 'thumbnail' : 'original',
);
} catch {
res.status(404).json({ error: 'Provider not supported' });
}
+21 -9
View File
@@ -1,6 +1,5 @@
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import {
searchPlaces,
getPlaceDetails,
@@ -10,8 +9,10 @@ import {
resolveGoogleMapsUrl,
autocompletePlaces,
} from '../services/mapsService';
import { db } from '../db/database';
import { serveFilePath } from '../services/placePhotoCache';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
@@ -35,7 +36,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
// POST /autocomplete
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
const autocompleteEnabledRow = db
.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'")
.get() as { value: string } | undefined;
if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' });
const authReq = req as AuthRequest;
@@ -51,9 +54,14 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
if (locationBias) {
const { low, high } = locationBias;
if (!low || !high
|| !Number.isFinite(low.lat) || !Number.isFinite(low.lng)
|| !Number.isFinite(high.lat) || !Number.isFinite(high.lng)) {
if (
!low ||
!high ||
!Number.isFinite(low.lat) ||
!Number.isFinite(low.lng) ||
!Number.isFinite(high.lat) ||
!Number.isFinite(high.lng)
) {
return res.status(400).json({ error: 'Invalid locationBias: low and high must have finite lat and lng' });
}
}
@@ -76,7 +84,9 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
// GET /details/:placeId
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as
| { value: string }
| undefined;
if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true });
const authReq = req as AuthRequest;
@@ -104,7 +114,9 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
// Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed
if (!placeId.startsWith('coords:')) {
const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as
| { value: string }
| undefined;
if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null });
}
const lat = parseFloat(req.query.lat as string);
+12 -11
View File
@@ -1,9 +1,7 @@
import express, { Request, Response } from 'express';
import { canAccessTrip } from '../../db/database';
import { authenticate } from '../../middleware/auth';
import { broadcast } from '../../websocket';
import { AuthRequest } from '../../types';
import { getClientIp } from '../../services/auditLog';
import { canAccessUserPhoto } from '../../services/memories/helpersService';
import {
getConnectionSettings,
saveImmichSettings,
@@ -19,7 +17,10 @@ import {
getAssetInfo,
isValidAssetId,
} from '../../services/memories/immichService';
import { canAccessUserPhoto } from '../../services/memories/helpersService';
import { AuthRequest } from '../../types';
import { broadcast } from '../../websocket';
import express, { Request, Response } from 'express';
const router = express.Router();
@@ -58,7 +59,7 @@ router.post('/test', authenticate, async (req: Request, res: Response) => {
router.get('/browse', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await browseTimeline(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ buckets: result.buckets });
});
@@ -68,7 +69,7 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.min(Number(size) || 50, 200);
const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
});
@@ -83,7 +84,7 @@ router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: R
return res.status(403).json({ error: 'Forbidden' });
}
const result = await getAssetInfo(authReq.user.id, assetId, Number(ownerId));
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json(result.data);
});
@@ -116,14 +117,14 @@ router.get('/assets/:tripId/:assetId/:ownerId/original', authenticate, async (re
router.get('/albums', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await listAlbums(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ albums: result.albums });
});
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await getAlbumPhotos(authReq.user.id, req.params.albumId);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ assets: result.assets });
});
@@ -132,9 +133,9 @@ router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req:
const { tripId, linkId } = req.params;
const sid = req.headers['x-socket-id'] as string;
const result = await syncAlbumAssets(tripId, linkId, authReq.user.id, sid);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ success: true, added: result.added, total: result.total });
if (result.added! > 0) {
if (result.added > 0) {
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
}
});
+119 -101
View File
@@ -1,150 +1,168 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../../middleware/auth';
import { AuthRequest } from '../../types';
import {
getSynologySettings,
updateSynologySettings,
getSynologyStatus,
testSynologyConnection,
listSynologyAlbums,
getSynologyAlbumPhotos,
syncSynologyAlbumLink,
searchSynologyPhotos,
getSynologyAssetInfo,
streamSynologyAsset,
} from '../../services/memories/synologyService';
import { canAccessUserPhoto, handleServiceResult, fail, success } from '../../services/memories/helpersService';
import {
getSynologySettings,
updateSynologySettings,
getSynologyStatus,
testSynologyConnection,
listSynologyAlbums,
getSynologyAlbumPhotos,
syncSynologyAlbumLink,
searchSynologyPhotos,
getSynologyAssetInfo,
streamSynologyAsset,
} from '../../services/memories/synologyService';
import { AuthRequest } from '../../types';
import express, { Request, Response } from 'express';
const router = express.Router();
function _parseStringBodyField(value: unknown): string {
return String(value ?? '').trim();
return String(value ?? '').trim();
}
function _parseNumberBodyField(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
router.get('/settings', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologySettings(authReq.user.id));
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologySettings(authReq.user.id));
});
router.put('/settings', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username) {
handleServiceResult(res, fail('URL and username are required', 400));
}
else {
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
}
if (!synology_url || !synology_username) {
handleServiceResult(res, fail('URL and username are required', 400));
} else {
handleServiceResult(
res,
await updateSynologySettings(
authReq.user.id,
synology_url,
synology_username,
synology_password,
synology_skip_ssl,
),
);
}
});
router.get('/status', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologyStatus(authReq.user.id));
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologyStatus(authReq.user.id));
});
router.post('/test', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username || !synology_password) {
const missingFields: string[] = [];
if (!synology_url) missingFields.push('URL');
if (!synology_username) missingFields.push('Username');
if (!synology_password) missingFields.push('Password');
handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
}
else{
handleServiceResult(res, await testSynologyConnection(authReq.user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
}
if (!synology_url || !synology_username || !synology_password) {
const missingFields: string[] = [];
if (!synology_url) missingFields.push('URL');
if (!synology_username) missingFields.push('Username');
if (!synology_password) missingFields.push('Password');
handleServiceResult(
res,
success({
connected: false,
error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required`,
}),
);
} else {
handleServiceResult(
res,
await testSynologyConnection(
authReq.user.id,
synology_url,
synology_username,
synology_password,
synology_otp,
synology_skip_ssl,
),
);
}
});
router.get('/albums', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
const authReq = req as AuthRequest;
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
});
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase));
const authReq = req as AuthRequest;
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase));
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
const sid = req.headers['x-socket-id'] as string;
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
const sid = req.headers['x-socket-id'] as string;
handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId, sid));
handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId, sid));
});
router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to);
let offset = _parseNumberBodyField(body.offset, 0);
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if (size > 0) limit = size;
if (page > 0) offset = page * limit;
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to);
let offset = _parseNumberBodyField(body.offset, 0);
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if (size > 0) limit = size;
if (page > 0) offset = page * limit;
handleServiceResult(res, await searchSynologyPhotos(
authReq.user.id,
from || undefined,
to || undefined,
offset,
limit,
));
handleServiceResult(
res,
await searchSynologyPhotos(authReq.user.id, from || undefined, to || undefined, offset, limit),
);
});
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, photoId, ownerId } = req.params;
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
const authReq = req as AuthRequest;
const { tripId, photoId, ownerId } = req.params;
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
}
else {
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase));
}
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
handleServiceResult(res, fail("You don't have access to this photo", 403));
} else {
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase));
}
});
router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, photoId, ownerId, kind } = req.params;
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
const rawSize = String(req.query.size ?? 'sm');
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
const authReq = req as AuthRequest;
const { tripId, photoId, ownerId, kind } = req.params;
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
const rawSize = String(req.query.size ?? 'sm');
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
if (kind !== 'thumbnail' && kind !== 'original') {
return handleServiceResult(res, fail('Invalid asset kind', 400));
}
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
}
else{
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size), passphrase);
}
if (kind !== 'thumbnail' && kind !== 'original') {
return handleServiceResult(res, fail('Invalid asset kind', 400));
}
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
handleServiceResult(res, fail("You don't have access to this photo", 403));
} else {
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind, String(size), passphrase);
}
});
export default router;
+58 -64
View File
@@ -1,18 +1,19 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../../middleware/auth';
import { AuthRequest } from '../../types';
import { Selection } from '../../services/memories/helpersService';
import {
listTripPhotos,
listTripAlbumLinks,
createTripAlbumLink,
removeAlbumLink,
addTripPhotos,
removeTripPhoto,
setTripPhotoSharing,
listTripPhotos,
listTripAlbumLinks,
createTripAlbumLink,
removeAlbumLink,
addTripPhotos,
removeTripPhoto,
setTripPhotoSharing,
} from '../../services/memories/unifiedService';
import { AuthRequest } from '../../types';
import immichRouter from './immich';
import synologyRouter from './synology';
import { Selection } from '../../services/memories/helpersService';
import express, { Request, Response } from 'express';
const router = express.Router();
@@ -23,82 +24,75 @@ router.use('/synologyphotos', synologyRouter);
// routes for managing photos linked to trip
router.get('/unified/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = listTripPhotos(tripId, authReq.user.id);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ photos: result.data });
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = listTripPhotos(tripId, authReq.user.id);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ photos: result.data });
});
router.post('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const sid = req.headers['x-socket-id'] as string;
const selections: Selection[] = Array.isArray(req.body?.selections) ? req.body.selections : [];
const shared = req.body?.shared === undefined ? true : !!req.body?.shared;
const result = await addTripPhotos(
tripId,
authReq.user.id,
shared,
selections,
sid,
);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
const authReq = req as AuthRequest;
const { tripId } = req.params;
const sid = req.headers['x-socket-id'] as string;
const selections: Selection[] = Array.isArray(req.body?.selections) ? req.body.selections : [];
res.json({ success: true, added: result.data.added });
const shared = req.body?.shared === undefined ? true : !!req.body?.shared;
const result = await addTripPhotos(tripId, authReq.user.id, shared, selections, sid);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true, added: result.data.added });
});
router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = await setTripPhotoSharing(
tripId,
authReq.user.id,
Number(req.body?.photo_id),
req.body?.shared,
);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = await setTripPhotoSharing(tripId, authReq.user.id, Number(req.body?.photo_id), req.body?.shared);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id));
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id));
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
//------------------------------
// routes for managing album links
router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = listTripAlbumLinks(tripId, authReq.user.id);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ links: result.data });
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = listTripAlbumLinks(tripId, authReq.user.id);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ links: result.data });
});
router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const passphrase = req.body?.passphrase ? String(req.body.passphrase) : undefined;
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name, passphrase);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
const authReq = req as AuthRequest;
const { tripId } = req.params;
const passphrase = req.body?.passphrase ? String(req.body.passphrase) : undefined;
const result = createTripAlbumLink(
tripId,
authReq.user.id,
req.body?.provider,
req.body?.album_id,
req.body?.album_name,
passphrase,
);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
router.delete('/unified/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
const result = removeAlbumLink(tripId, linkId, authReq.user.id);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
const result = removeAlbumLink(tripId, linkId, authReq.user.id);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
export default router;
+18 -7
View File
@@ -1,7 +1,4 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { testSmtp, testWebhook, testNtfy, getAdminWebhookUrl, getUserWebhookUrl, getUserNtfyConfig, getAdminNtfyConfig } from '../services/notifications';
import {
getNotifications,
getUnreadCount,
@@ -13,6 +10,18 @@ import {
respondToBoolean,
} from '../services/inAppNotifications';
import { getPreferencesMatrix, setPreferences } from '../services/notificationPreferencesService';
import {
testSmtp,
testWebhook,
testNtfy,
getAdminWebhookUrl,
getUserWebhookUrl,
getUserNtfyConfig,
getAdminNtfyConfig,
} from '../services/notifications';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
@@ -43,7 +52,11 @@ router.post('/test-webhook', authenticate, async (req: Request, res: Response) =
if (!url) return res.status(400).json({ error: 'No webhook URL configured' });
}
if (typeof url !== 'string') return res.status(400).json({ error: 'url must be a string' });
try { new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
try {
new URL(url);
} catch {
return res.status(400).json({ error: 'Invalid URL' });
}
res.json(await testWebhook(url));
});
@@ -58,9 +71,7 @@ router.post('/test-ntfy', authenticate, async (req: Request, res: Response) => {
const resolvedTopic = topic || userCfg?.topic || undefined;
const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
// Reuse saved token when request sends null, empty, or the masked placeholder
const resolvedToken = (token && token !== '••••••••')
? token
: (userCfg?.token ?? adminCfg.token ?? null);
const resolvedToken = token && token !== '••••••••' ? token : (userCfg?.token ?? adminCfg.token ?? null);
if (!resolvedTopic) return res.status(400).json({ error: 'No ntfy topic configured' });
+123 -57
View File
@@ -1,9 +1,9 @@
import express, { Request, Response } from 'express';
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { isAddonEnabled } from '../services/adminService';
import { ALL_SCOPES } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import { ALL_SCOPES } from '../mcp/scopes';
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
import { isAddonEnabled } from '../services/adminService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
import {
validateAuthorizeRequest,
createAuthCode,
@@ -24,14 +24,18 @@ import {
getUserByAccessToken,
AuthorizeParams,
} from '../services/oauthService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
import { AuthRequest, OptionalAuthRequest } from '../types';
import express, { Request, Response } from 'express';
// ---------------------------------------------------------------------------
// Minimal in-file rate limiter (same pattern as auth.ts)
// ---------------------------------------------------------------------------
interface RateEntry { count: number; first: number; }
interface RateEntry {
count: number;
first: number;
}
function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Request) => string) {
const store = new Map<string, RateEntry>();
@@ -45,7 +49,9 @@ function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Req
const now = Date.now();
const record = store.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
res
.status(429)
.json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
}
if (!record || now - record.first >= windowMs) {
@@ -57,9 +63,9 @@ function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Req
};
}
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
// ---------------------------------------------------------------------------
// Public router: /oauth/token and /oauth/revoke
@@ -88,30 +94,52 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
// ---- authorization_code grant ----
if (grant_type === 'authorization_code') {
if (!code || !redirect_uri || !code_verifier) {
return res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
return res
.status(400)
.json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
}
const pending = consumeAuthCode(code);
// H5: collapse all invalid_grant cases to one message; log specifics server-side
if (!pending) {
writeAudit({ userId: null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'code_invalid_or_expired' }, ip });
writeAudit({
userId: null,
action: 'oauth.token.grant_failed',
details: { client_id, reason: 'code_invalid_or_expired' },
ip,
});
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.clientId !== client_id) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'client_id_mismatch' }, ip });
writeAudit({
userId: pending.userId,
action: 'oauth.token.grant_failed',
details: { client_id, reason: 'client_id_mismatch' },
ip,
});
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.redirectUri !== redirect_uri) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'redirect_uri_mismatch' }, ip });
writeAudit({
userId: pending.userId,
action: 'oauth.token.grant_failed',
details: { client_id, reason: 'redirect_uri_mismatch' },
ip,
});
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// RFC 8707: if the auth code was bound to a resource, the token request must present the same value
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'resource_mismatch' }, ip });
writeAudit({
userId: pending.userId,
action: 'oauth.token.grant_failed',
details: { client_id, reason: 'resource_mismatch' },
ip,
});
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
@@ -124,12 +152,22 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
// Verify PKCE
if (!verifyPKCE(code_verifier, pending.codeChallenge)) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'pkce_failed' }, ip });
writeAudit({
userId: pending.userId,
action: 'oauth.token.grant_failed',
details: { client_id, reason: 'pkce_failed' },
ip,
});
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
const tokens = issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
writeAudit({
userId: pending.userId,
action: 'oauth.token.issue',
details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null },
ip,
});
return res.json(tokens);
}
@@ -146,7 +184,8 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
}
return res.status(result.status || 400).json({
error: result.error,
error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
error_description:
result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
});
}
@@ -156,7 +195,9 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
// ---- client_credentials grant ----
if (grant_type === 'client_credentials') {
if (!client_secret) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
return res
.status(401)
.json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
}
const client = authenticateClient(client_id, client_secret);
@@ -168,8 +209,16 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
// Public clients and DCR-anonymous clients are ineligible for client_credentials.
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
writeAudit({
userId: client.user_id ?? null,
action: 'oauth.token.grant_failed',
details: { client_id, reason: 'unauthorized_client' },
ip,
});
return res.status(400).json({
error: 'unauthorized_client',
error_description: 'This client is not authorized for the client_credentials grant',
});
}
// Scope: use requested subset or fall back to all allowed scopes.
@@ -177,9 +226,12 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
let grantedScopes: string[];
if (body.scope) {
const requested = body.scope.split(' ').filter(Boolean);
const invalid = requested.filter(s => !allowedScopes.includes(s));
const invalid = requested.filter((s) => !allowedScopes.includes(s));
if (invalid.length > 0) {
return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
return res.status(400).json({
error: 'invalid_scope',
error_description: `Scopes not allowed for this client: ${invalid.join(', ')}`,
});
}
grantedScopes = requested;
} else {
@@ -191,11 +243,18 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
writeAudit({
userId: client.user_id,
action: 'oauth.token.issue',
details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' },
ip,
});
return res.json(tokens);
}
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
return res
.status(400)
.json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
});
// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3)
@@ -214,8 +273,8 @@ oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => {
return res.status(401).json({ error: 'invalid_token' });
}
return res.json({
sub: String(info.user.id),
email: info.user.email,
sub: String(info.user.id),
email: info.user.email,
email_verified: true,
preferred_username: info.user.username,
});
@@ -234,7 +293,12 @@ oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Respo
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
writeAudit({
userId: null,
action: 'oauth.token.client_auth_failed',
details: { client_id, endpoint: 'revoke' },
ip,
});
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
@@ -258,17 +322,17 @@ oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: R
const userId = (req as OptionalAuthRequest).user?.id ?? null;
const result = validateAuthorizeRequest(
{
response_type: params.response_type || '',
client_id: params.client_id || '',
redirect_uri: params.redirect_uri || '',
scope: params.scope || '',
state: params.state,
code_challenge: params.code_challenge || '',
code_challenge_method: params.code_challenge_method || '',
resource: typeof params.resource === 'string' ? params.resource : undefined,
},
userId,
{
response_type: params.response_type || '',
client_id: params.client_id || '',
redirect_uri: params.redirect_uri || '',
scope: params.scope || '',
state: params.state,
code_challenge: params.code_challenge || '',
code_challenge_method: params.code_challenge_method || '',
resource: typeof params.resource === 'string' ? params.resource : undefined,
},
userId,
);
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
@@ -288,19 +352,17 @@ oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: R
// User submits consent (approve or deny) — requires cookie-only auth (M7)
oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Response) => {
const { user } = req as AuthRequest;
const {
client_id, redirect_uri, scope, state,
code_challenge, code_challenge_method, approved, resource,
} = req.body as {
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
approved: boolean;
resource?: string;
};
const { client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, approved, resource } =
req.body as {
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
approved: boolean;
resource?: string;
};
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
@@ -333,7 +395,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
return res.status(400).json({ error: validation.error, error_description: validation.error_description });
}
const scopes = validation.scopes!;
const scopes = validation.scopes;
// Store consent (union with any existing scopes)
saveConsent(client_id, user.id, scopes, ip);
@@ -350,7 +412,9 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
});
if (!code) {
return res.status(503).json({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' });
return res
.status(503)
.json({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' });
}
const url = new URL(redirect_uri);
@@ -378,7 +442,9 @@ oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response)
allows_client_credentials?: boolean;
};
const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials });
const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), {
allowsClientCredentials: allows_client_credentials,
});
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.status(201).json(result);
});
@@ -413,4 +479,4 @@ oauthApiRouter.delete('/sessions/:id', requireCookieAuth, (req: Request, res: Re
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
});
+4 -3
View File
@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express';
import { resolveAuthToggles } from '../services/authService';
import { setAuthCookie } from '../services/cookie';
import { getAppUrl } from '../services/notifications';
import {
getOidcConfig,
discover,
@@ -15,8 +16,8 @@ import {
generateToken,
frontendUrl,
} from '../services/oidcService';
import { getAppUrl } from '../services/notifications';
import { resolveAuthToggles } from '../services/authService';
import express, { Request, Response } from 'express';
const router = express.Router();
+64 -19
View File
@@ -1,9 +1,5 @@
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import {
verifyTripAccess,
listItems,
@@ -22,6 +18,11 @@ import {
updateCategoryAssignees,
reorderItems,
} from '../services/packingService';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
const router = express.Router({ mergeParams: true });
@@ -45,10 +46,13 @@ router.post('/import', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' });
if (!Array.isArray(items) || items.length === 0)
return res.status(400).json({ error: 'items must be a non-empty array' });
const created = bulkImport(tripId, items);
@@ -66,7 +70,9 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!name) return res.status(400).json({ error: 'Item name is required' });
@@ -84,7 +90,9 @@ router.put('/reorder', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
reorderItems(tripId, orderedIds);
@@ -99,10 +107,17 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const updated = updateItem(tripId, id, { name, checked, category, weight_grams, bag_id, quantity }, Object.keys(req.body));
const updated = updateItem(
tripId,
id,
{ name, checked, category, weight_grams, bag_id, quantity },
Object.keys(req.body),
);
if (!updated) return res.status(404).json({ error: 'Item not found' });
res.json({ item: updated });
@@ -116,7 +131,9 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' });
@@ -142,7 +159,9 @@ router.post('/bags', authenticate, (req: Request, res: Response) => {
const { name, color } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const bag = createBag(tripId, { name, color });
@@ -156,7 +175,9 @@ router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => {
const { name, color, weight_limit_grams, user_id } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const updated = updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(req.body));
if (!updated) return res.status(404).json({ error: 'Bag not found' });
@@ -169,7 +190,9 @@ router.delete('/bags/:bagId', authenticate, (req: Request, res: Response) => {
const { tripId, bagId } = req.params;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!deleteBag(tripId, bagId)) return res.status(404).json({ error: 'Bag not found' });
res.json({ success: true });
@@ -185,7 +208,9 @@ router.post('/apply-template/:templateId', authenticate, (req: Request, res: Res
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const added = applyTemplate(tripId, templateId);
@@ -203,12 +228,19 @@ router.put('/bags/:bagId/members', authenticate, (req: Request, res: Response) =
const { user_ids } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const members = setBagMembers(tripId, bagId, Array.isArray(user_ids) ? user_ids : []);
if (!members) return res.status(404).json({ error: 'Bag not found' });
res.json({ members });
broadcast(tripId, 'packing:bag-members-updated', { bagId: Number(bagId), members }, req.headers['x-socket-id'] as string);
broadcast(
tripId,
'packing:bag-members-updated',
{ bagId: Number(bagId), members },
req.headers['x-socket-id'] as string,
);
});
// ── Save as Template ───────────────────────────────────────────────────────
@@ -249,7 +281,9 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const cat = decodeURIComponent(categoryName);
@@ -263,7 +297,18 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
import('../services/notificationService').then(({ send }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
// Use trip scope so the service resolves recipients — actor is excluded automatically
send({ event: 'packing_tagged', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat, tripId: String(tripId) } }).catch(() => {});
send({
event: 'packing_tagged',
actorId: authReq.user.id,
scope: 'trip',
targetId: Number(tripId),
params: {
trip: tripInfo?.title || 'Untitled',
actor: authReq.user.email,
category: cat,
tripId: String(tripId),
},
}).catch(() => {});
});
}
});
+4 -3
View File
@@ -1,8 +1,9 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { streamPhoto, getPhotoInfo, resolveTrekPhoto } from '../services/memories/photoResolverService';
import { canAccessTrekPhoto } from '../services/memories/helpersService';
import { streamPhoto, getPhotoInfo, resolveTrekPhoto } from '../services/memories/photoResolverService';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
+208 -103
View File
@@ -1,11 +1,8 @@
import express, { Request, Response } from 'express';
import multer from 'multer';
import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import {
listPlaces,
createPlace,
@@ -20,7 +17,11 @@ import {
searchPlaceImage,
type KmlImportOptions,
} from '../services/placeService';
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
import { AuthRequest } from '../types';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
import multer from 'multer';
const uploadMulter = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
@@ -39,94 +40,148 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
res.json({ places });
});
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
router.post(
'/',
authenticate,
requireTripAccess,
validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }),
(req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (
!checkPermission(
'place_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const { name } = req.body;
const { tripId } = req.params;
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Place name is required' });
}
const place = createPlace(tripId, req.body);
res.status(201).json({ place });
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
try { onPlaceCreated(Number(tripId), place.id); } catch {}
});
// Import places from GPX file with full track geometry (must be before /:id)
router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const file = req.file as Express.Multer.File | undefined;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
const importWaypoints = parseBool(req.body.importWaypoints, true);
const importRoutes = parseBool(req.body.importRoutes, true);
const importTracks = parseBool(req.body.importTracks, true);
if (!importWaypoints && !importRoutes && !importTracks) {
return res.status(400).json({ error: 'No import types selected' });
}
const result = importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks });
if (!result) {
return res.status(400).json({ error: 'No matching places found in GPX file' });
}
res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped });
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
});
router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single('file'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) {
return res.status(403).json({ error: 'No permission' });
}
const { tripId } = req.params;
const file = req.file as Express.Multer.File | undefined;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
const importPoints = parseBool(req.body.importPoints, true);
const importPaths = parseBool(req.body.importPaths, true);
if (!importPoints && !importPaths) {
return res.status(400).json({ error: 'No import types selected' });
}
const kmlOpts: KmlImportOptions = { importPoints, importPaths };
try {
const result = await importMapFile(tripId, file.buffer, file.originalname, kmlOpts);
if (result.summary?.totalPlacemarks === 0) {
return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary });
if (!name) {
return res.status(400).json({ error: 'Place name is required' });
}
res.status(201).json(result);
const place = createPlace(tripId, req.body);
res.status(201).json({ place });
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
try {
onPlaceCreated(Number(tripId), place.id);
} catch {}
},
);
// Import places from GPX file with full track geometry (must be before /:id)
router.post(
'/import/gpx',
authenticate,
requireTripAccess,
uploadMulter.single('file'),
(req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (
!checkPermission(
'place_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const file = req.file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
const parseBool = (v: unknown, defaultVal: boolean) =>
v === undefined || v === null ? defaultVal : String(v) === 'true';
const importWaypoints = parseBool(req.body.importWaypoints, true);
const importRoutes = parseBool(req.body.importRoutes, true);
const importTracks = parseBool(req.body.importTracks, true);
if (!importWaypoints && !importRoutes && !importTracks) {
return res.status(400).json({ error: 'No import types selected' });
}
const result = importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks });
if (!result) {
return res.status(400).json({ error: 'No matching places found in GPX file' });
}
res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped });
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to import map file';
res.status(400).json({ error: message });
}
});
},
);
router.post(
'/import/map',
authenticate,
requireTripAccess,
uploadMulter.single('file'),
async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (
!checkPermission(
'place_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
) {
return res.status(403).json({ error: 'No permission' });
}
const { tripId } = req.params;
const file = req.file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
const parseBool = (v: unknown, defaultVal: boolean) =>
v === undefined || v === null ? defaultVal : String(v) === 'true';
const importPoints = parseBool(req.body.importPoints, true);
const importPaths = parseBool(req.body.importPaths, true);
if (!importPoints && !importPaths) {
return res.status(400).json({ error: 'No import types selected' });
}
const kmlOpts: KmlImportOptions = { importPoints, importPaths };
try {
const result = await importMapFile(tripId, file.buffer, file.originalname, kmlOpts);
if (result.summary?.totalPlacemarks === 0) {
return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary });
}
res.status(201).json(result);
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to import map file';
res.status(400).json({ error: message });
}
},
);
// Import places from a shared Google Maps list URL
router.post('/import/google-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'place_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
@@ -140,7 +195,9 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req:
return res.status(result.status).json({ error: result.error });
}
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
res
.status(201)
.json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
@@ -153,7 +210,15 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req:
// Import places from a shared Naver Maps list URL
router.post('/import/naver-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'place_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const { url } = req.body;
@@ -166,7 +231,9 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R
return res.status(result.status).json({ error: result.error });
}
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
res
.status(201)
.json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
@@ -205,38 +272,66 @@ router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, r
}
});
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
router.put(
'/:id',
authenticate,
requireTripAccess,
validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }),
(req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (
!checkPermission(
'place_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
const { tripId, id } = req.params;
const place = updatePlace(tripId, id, req.body);
if (!place) {
return res.status(404).json({ error: 'Place not found' });
}
const place = updatePlace(tripId, id, req.body);
if (!place) {
return res.status(404).json({ error: 'Place not found' });
}
res.json({ place });
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
try { onPlaceUpdated(place.id); } catch {}
});
res.json({ place });
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
try {
onPlaceUpdated(place.id);
} catch {}
},
);
// Bulk delete (must be before /:id)
router.post('/bulk-delete', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'place_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const { ids } = req.body as { ids?: unknown };
if (!Array.isArray(ids) || ids.some(v => typeof v !== 'number'))
if (!Array.isArray(ids) || ids.some((v) => typeof v !== 'number'))
return res.status(400).json({ error: 'ids must be an array of numbers' });
const idList = ids as number[];
if (idList.length === 0) return res.json({ deleted: [], count: 0 });
for (const id of idList) { try { onPlaceDeleted(id); } catch {} }
for (const id of idList) {
try {
onPlaceDeleted(id);
} catch {}
}
const deleted = deletePlacesMany(tripId, idList);
res.json({ deleted, count: deleted.length });
@@ -248,12 +343,22 @@ router.post('/bulk-delete', authenticate, requireTripAccess, (req: Request, res:
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
if (
!checkPermission(
'place_edit',
authReq.user.role,
authReq.trip.user_id,
authReq.user.id,
authReq.trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { tripId, id } = req.params;
try { onPlaceDeleted(Number(id)); } catch {} // sync before actual delete
try {
onPlaceDeleted(Number(id));
} catch {} // sync before actual delete
const deleted = deletePlace(tripId, id);
if (!deleted) {
return res.status(404).json({ error: 'Place not found' });
+2 -1
View File
@@ -1,6 +1,7 @@
import express, { Request, Response } from 'express';
import { DEFAULT_LANGUAGE } from '../config';
import express, { Request, Response } from 'express';
const router = express.Router();
router.get('/', (_req: Request, res: Response) => {
+178 -26
View File
@@ -1,9 +1,12 @@
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import {
createBudgetItem,
updateBudgetItem,
deleteBudgetItem,
linkBudgetItemToReservation,
} from '../services/budgetService';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import {
verifyTripAccess,
listReservations,
@@ -13,7 +16,10 @@ import {
updateReservation,
deleteReservation,
} from '../services/reservationService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService';
import { AuthRequest } from '../types';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
const router = express.Router({ mergeParams: true });
@@ -31,21 +37,61 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
const {
title,
reservation_time,
reservation_end_time,
location,
confirmation_number,
notes,
day_id,
end_day_id,
place_id,
assignment_id,
status,
type,
accommodation_id,
metadata,
create_accommodation,
create_budget_entry,
endpoints,
needs_review,
} = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission(
'reservation_edit',
authReq.user.role,
trip.user_id,
authReq.user.id,
trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
if (!title) return res.status(400).json({ error: 'Title is required' });
const { reservation, accommodationCreated } = createReservation(tripId, {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
title,
reservation_time,
reservation_end_time,
location,
confirmation_number,
notes,
day_id,
end_day_id,
place_id,
assignment_id,
status,
type,
accommodation_id,
metadata,
create_accommodation,
endpoints,
needs_review,
});
if (accommodationCreated) {
@@ -72,7 +118,19 @@ router.post('/', authenticate, (req: Request, res: Response) => {
// Notify trip members about new booking
import('../services/notificationService').then(({ send }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking', tripId: String(tripId) } }).catch(() => {});
send({
event: 'booking_change',
actorId: authReq.user.id,
scope: 'trip',
targetId: Number(tripId),
params: {
trip: tripInfo?.title || 'Untitled',
actor: authReq.user.email,
booking: title,
type: type || 'booking',
tripId: String(tripId),
},
}).catch(() => {});
});
});
@@ -85,7 +143,15 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission(
'reservation_edit',
authReq.user.role,
trip.user_id,
authReq.user.id,
trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
@@ -100,23 +166,68 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
const {
title,
reservation_time,
reservation_end_time,
location,
confirmation_number,
notes,
day_id,
end_day_id,
place_id,
assignment_id,
status,
type,
accommodation_id,
metadata,
create_accommodation,
create_budget_entry,
endpoints,
needs_review,
} = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission(
'reservation_edit',
authReq.user.role,
trip.user_id,
authReq.user.id,
trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const current = getReservation(id, tripId);
if (!current) return res.status(404).json({ error: 'Reservation not found' });
const { reservation, accommodationChanged } = updateReservation(id, tripId, {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
}, current);
const { reservation, accommodationChanged } = updateReservation(
id,
tripId,
{
title,
reservation_time,
reservation_end_time,
location,
confirmation_number,
notes,
day_id,
end_day_id,
place_id,
assignment_id,
status,
type,
accommodation_id,
metadata,
create_accommodation,
endpoints,
needs_review,
},
current,
);
if (accommodationChanged) {
broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string);
@@ -124,7 +235,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
// Remove linked budget entry if price was cleared
if (!create_budget_entry || !create_budget_entry.total_price) {
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
const linked = db
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(tripId, id) as { id: number } | undefined;
if (linked) {
deleteBudgetItem(linked.id, tripId);
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, req.headers['x-socket-id'] as string);
@@ -135,7 +248,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
if (create_budget_entry && create_budget_entry.total_price > 0) {
try {
const itemName = title || current.title;
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
const existing = db
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(tripId, id) as { id: number } | undefined;
if (existing) {
const updated = updateBudgetItem(existing.id, tripId, {
name: itemName,
@@ -163,7 +278,19 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
import('../services/notificationService').then(({ send }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking', tripId: String(tripId) } }).catch(() => {});
send({
event: 'booking_change',
actorId: authReq.user.id,
scope: 'trip',
targetId: Number(tripId),
params: {
trip: tripInfo?.title || 'Untitled',
actor: authReq.user.email,
booking: title || current.title,
type: type || current.type || 'booking',
tripId: String(tripId),
},
}).catch(() => {});
});
});
@@ -174,14 +301,27 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission(
'reservation_edit',
authReq.user.role,
trip.user_id,
authReq.user.id,
trip.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { deleted: reservation, accommodationDeleted, deletedBudgetItemId } = deleteReservation(id, tripId);
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
if (accommodationDeleted) {
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string);
broadcast(
tripId,
'accommodation:deleted',
{ accommodationId: reservation.accommodation_id },
req.headers['x-socket-id'] as string,
);
}
if (deletedBudgetItemId) {
broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string);
@@ -192,7 +332,19 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
import('../services/notificationService').then(({ send }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking', tripId: String(tripId) } }).catch(() => {});
send({
event: 'booking_change',
actorId: authReq.user.id,
scope: 'trip',
targetId: Number(tripId),
params: {
trip: tripInfo?.title || 'Untitled',
actor: authReq.user.email,
booking: reservation.title,
type: reservation.type || 'booking',
tripId: String(tripId),
},
}).catch(() => {});
});
});
+4 -4
View File
@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import * as settingsService from '../services/settingsService';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
@@ -22,8 +23,7 @@ router.put('/', authenticate, (req: Request, res: Response) => {
router.post('/bulk', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { settings } = req.body;
if (!settings || typeof settings !== 'object')
return res.status(400).json({ error: 'Settings object is required' });
if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'Settings object is required' });
try {
const updated = settingsService.bulkUpsertSettings(authReq.user.id, settings);
res.json({ success: true, updated });
+26 -5
View File
@@ -1,9 +1,10 @@
import express, { Request, Response } from 'express';
import { canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import * as shareService from '../services/shareService';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
@@ -13,12 +14,24 @@ router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Respo
const { tripId } = req.params;
const access = canAccessTrip(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'share_manage',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
const { share_map, share_bookings, share_packing, share_budget, share_collab } = req.body || {};
const result = shareService.createOrUpdateShareLink(tripId, authReq.user.id, {
share_map, share_bookings, share_packing, share_budget, share_collab,
share_map,
share_bookings,
share_packing,
share_budget,
share_collab,
});
if (result.created) {
@@ -43,7 +56,15 @@ router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Res
const { tripId } = req.params;
const access = canAccessTrip(tripId, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
if (
!checkPermission(
'share_manage',
authReq.user.role,
access.user_id,
authReq.user.id,
access.user_id !== authReq.user.id,
)
)
return res.status(403).json({ error: 'No permission' });
shareService.deleteShareLink(tripId);
+4 -3
View File
@@ -1,14 +1,15 @@
import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';
import { getActiveNoticesFor, dismissNotice } from '../systemNotices/service.js';
import type { AuthRequest } from '../types.js';
import { Router } from 'express';
const router = Router();
// GET /api/system-notices/active
// Returns notices active for the authenticated user.
router.get('/active', authenticate, (req, res) => {
const userId = (req as AuthRequest).user!.id;
const userId = (req as AuthRequest).user.id;
const notices = getActiveNoticesFor(userId);
res.json(notices);
});
@@ -16,7 +17,7 @@ router.get('/active', authenticate, (req, res) => {
// POST /api/system-notices/:id/dismiss
// Marks a notice as dismissed for the authenticated user. Idempotent.
router.post('/:id/dismiss', authenticate, (req, res) => {
const userId = (req as AuthRequest).user!.id;
const userId = (req as AuthRequest).user.id;
const noticeId = req.params.id;
const ok = dismissNotice(userId, noticeId);
if (!ok) {
+3 -2
View File
@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import * as tagService from '../services/tagService';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
+25 -9
View File
@@ -1,8 +1,5 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import {
verifyTripAccess,
listItems,
@@ -13,6 +10,10 @@ import {
updateCategoryAssignees,
reorderItems,
} from '../services/todoService';
import { AuthRequest } from '../types';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
const router = express.Router({ mergeParams: true });
@@ -35,7 +36,9 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!name) return res.status(400).json({ error: 'Item name is required' });
@@ -53,7 +56,9 @@ router.put('/reorder', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
reorderItems(tripId, orderedIds);
@@ -68,10 +73,17 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const updated = updateItem(tripId, id, { name, checked, category, due_date, description, assigned_user_id, priority }, Object.keys(req.body));
const updated = updateItem(
tripId,
id,
{ name, checked, category, due_date, description, assigned_user_id, priority },
Object.keys(req.body),
);
if (!updated) return res.status(404).json({ error: 'Item not found' });
res.json({ item: updated });
@@ -85,7 +97,9 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
if (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' });
@@ -114,7 +128,9 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
if (
!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)
)
return res.status(403).json({ error: 'No permission' });
const cat = decodeURIComponent(categoryName);
+76 -32
View File
@@ -1,14 +1,14 @@
import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { db, canAccessTrip } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, Trip } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import { listBudgetItems } from '../services/budgetService';
import { listDays, listAccommodations } from '../services/dayService';
import { listFiles } from '../services/fileService';
import { listItems as listPackingItems } from '../services/packingService';
import { checkPermission } from '../services/permissions';
import { listPlaces } from '../services/placeService';
import { listReservations } from '../services/reservationService';
import { listItems as listTodoItems } from '../services/todoService';
import {
listTrips,
createTrip,
@@ -29,13 +29,14 @@ import {
ValidationError,
TRIP_SELECT,
} 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 { listBudgetItems } from '../services/budgetService';
import { listReservations } from '../services/reservationService';
import { listFiles } from '../services/fileService';
import { AuthRequest, Trip } from '../types';
import { broadcast } from '../websocket';
import express, { Request, Response } from 'express';
import fs from 'fs';
import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
@@ -86,7 +87,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
if (!title) return res.status(400).json({ error: 'Title is required' });
const toDateStr = (d: Date) => d.toISOString().slice(0, 10);
const addDays = (d: Date, n: number) => { const r = new Date(d); r.setDate(r.getDate() + n); return r; };
const addDays = (d: Date, n: number) => {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
};
let start_date: string | null = req.body.start_date || null;
let end_date: string | null = req.body.end_date || null;
@@ -103,9 +108,22 @@ router.post('/', authenticate, (req: Request, res: Response) => {
return res.status(400).json({ error: 'End date must be after start date' });
const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), 365) : undefined;
const { trip, tripId, reminderDays } = createTrip(authReq.user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount });
const { trip, tripId, reminderDays } = createTrip(authReq.user.id, {
title,
description,
start_date,
end_date,
currency,
reminder_days,
day_count: parsedDayCount,
});
writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } });
writeAudit({
userId: authReq.user.id,
action: 'trip.create',
ip: getClientIp(req),
details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` },
});
if (reminderDays > 0) {
logInfo(`${authReq.user.email} set ${reminderDays}-day reminder for trip "${title}"`);
}
@@ -144,7 +162,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
}
// General edit check (title, description, dates, currency, reminder_days)
const editFields = ['title', 'description', 'start_date', 'end_date', 'currency', 'reminder_days', 'day_count'];
if (editFields.some(f => req.body[f] !== undefined)) {
if (editFields.some((f) => req.body[f] !== undefined)) {
if (!checkPermission('trip_edit', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
return res.status(403).json({ error: 'No permission to edit this trip' });
}
@@ -153,7 +171,17 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const result = updateTrip(req.params.id, authReq.user.id, req.body, authReq.user.role);
if (Object.keys(result.changes).length > 0) {
writeAudit({ userId: authReq.user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(req.params.id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } });
writeAudit({
userId: authReq.user.id,
action: 'trip.update',
ip: getClientIp(req),
details: {
tripId: Number(req.params.id),
trip: result.newTitle,
...(result.ownerEmail ? { owner: result.ownerEmail } : {}),
...result.changes,
},
});
if (result.isAdminEdit && result.ownerEmail) {
logInfo(`Admin ${authReq.user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`);
}
@@ -204,12 +232,16 @@ router.post('/:id/copy', authenticate, (req: Request, res: Response) => {
if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false))
return res.status(403).json({ error: 'No permission to create trips' });
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
if (!canAccessTrip(req.params.id, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
try {
const newTripId = copyTripById(req.params.id, authReq.user.id, req.body.title);
writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId, title: req.body.title } });
writeAudit({
userId: authReq.user.id,
action: 'trip.copy',
ip: getClientIp(req),
details: { sourceTripId: Number(req.params.id), newTripId, title: req.body.title },
});
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: newTripId });
res.status(201).json({ trip });
} catch {
@@ -228,7 +260,12 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
const info = deleteTrip(req.params.id, authReq.user.id, authReq.user.role);
writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } });
writeAudit({
userId: authReq.user.id,
action: 'trip.delete',
ip: getClientIp(req),
details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) },
});
if (info.isAdminDelete && info.ownerEmail) {
logInfo(`Admin ${authReq.user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`);
}
@@ -242,8 +279,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
router.get('/:id/members', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const access = canAccessTrip(req.params.id, authReq.user.id);
if (!access)
return res.status(404).json({ error: 'Trip not found' });
if (!access) return res.status(404).json({ error: 'Trip not found' });
const { owner, members } = listMembers(req.params.id, access.user_id);
res.json({ owner, members, current_user_id: authReq.user.id });
@@ -254,8 +290,7 @@ router.get('/:id/members', authenticate, (req: Request, res: Response) => {
router.post('/:id/members', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const access = canAccessTrip(req.params.id, authReq.user.id);
if (!access)
return res.status(404).json({ error: 'Trip not found' });
if (!access) return res.status(404).json({ error: 'Trip not found' });
const tripOwnerId = access.user_id;
const isMember = tripOwnerId !== authReq.user.id;
@@ -269,7 +304,18 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
// Notify invited user
import('../services/notificationService').then(({ send }) => {
send({ event: 'trip_invite', actorId: authReq.user.id, scope: 'user', targetId: result.targetUserId, params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email, tripId: String(req.params.id) } }).catch(() => {});
send({
event: 'trip_invite',
actorId: authReq.user.id,
scope: 'user',
targetId: result.targetUserId,
params: {
trip: result.tripTitle,
actor: authReq.user.email,
invitee: result.member.email,
tripId: String(req.params.id),
},
}).catch(() => {});
});
res.status(201).json({ member: result.member });
@@ -284,8 +330,7 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
if (!canAccessTrip(req.params.id, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const targetId = parseInt(req.params.userId);
const isSelf = targetId === authReq.user.id;
@@ -340,8 +385,7 @@ router.get('/:id/bundle', authenticate, (req: Request, res: Response) => {
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
if (!canAccessTrip(req.params.id, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
try {
const { ics, filename } = exportICS(req.params.id);
+16 -8
View File
@@ -1,7 +1,8 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import * as svc from '../services/vacayService';
import { AuthRequest } from '../types';
import express, { Request, Response } from 'express';
const router = express.Router();
router.use(authenticate);
@@ -23,7 +24,14 @@ router.post('/plan/holiday-calendars', (req: Request, res: Response) => {
const { region, label, color, sort_order } = req.body;
if (!region) return res.status(400).json({ error: 'region required' });
const planId = svc.getActivePlanId(authReq.user.id);
const calendar = svc.addHolidayCalendar(planId, region, label, color, sort_order, req.headers['x-socket-id'] as string);
const calendar = svc.addHolidayCalendar(
planId,
region,
label,
color,
sort_order,
req.headers['x-socket-id'] as string,
);
res.json({ calendar });
});
@@ -51,7 +59,7 @@ router.put('/color', (req: Request, res: Response) => {
const planId = svc.getActivePlanId(authReq.user.id);
const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id;
const planUsers = svc.getPlanUsers(planId);
if (!planUsers.find(u => u.id === userId)) {
if (!planUsers.find((u) => u.id === userId)) {
return res.status(403).json({ error: 'User not in plan' });
}
svc.setUserColor(userId, planId, color, req.headers['x-socket-id'] as string);
@@ -64,7 +72,7 @@ router.post('/invite', (req: Request, res: Response) => {
if (!user_id) return res.status(400).json({ error: 'user_id required' });
const plan = svc.getActivePlan(authReq.user.id);
const result = svc.sendInvite(plan.id, authReq.user.id, authReq.user.username, authReq.user.email, user_id);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ success: true });
});
@@ -72,7 +80,7 @@ router.post('/invite/accept', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { plan_id } = req.body;
const result = svc.acceptInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.error) return res.status(result.status).json({ error: result.error });
res.json({ success: true });
});
@@ -142,7 +150,7 @@ router.post('/entries/toggle', (req: Request, res: Response) => {
if (target_user_id && parseInt(target_user_id) !== authReq.user.id) {
const planUsers = svc.getPlanUsers(planId);
const tid = parseInt(target_user_id);
if (!planUsers.find(u => u.id === tid)) {
if (!planUsers.find((u) => u.id === tid)) {
return res.status(403).json({ error: 'User not in plan' });
}
userId = tid;
@@ -171,7 +179,7 @@ router.put('/stats/:year', (req: Request, res: Response) => {
const planId = svc.getActivePlanId(authReq.user.id);
const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id;
const planUsers = svc.getPlanUsers(planId);
if (!planUsers.find(u => u.id === userId)) {
if (!planUsers.find((u) => u.id === userId)) {
return res.status(403).json({ error: 'User not in plan' });
}
svc.updateStats(userId, planId, year, vacation_days, req.headers['x-socket-id'] as string);
+205 -99
View File
@@ -1,9 +1,10 @@
import cron, { type ScheduledTask } from 'node-cron';
import archiver from 'archiver';
import path from 'node:path';
import fs from 'node:fs';
import { logInfo, logError } from './services/auditLog';
import archiver from 'archiver';
import cron, { type ScheduledTask } from 'node-cron';
import fs from 'node:fs';
import path from 'node:path';
const dataDir = path.join(__dirname, '../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
@@ -11,7 +12,7 @@ const settingsFile = path.join(dataDir, 'backup-settings.json');
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
const VALID_DAYS_OF_WEEK = new Set([0, 1, 2, 3, 4, 5, 6]); // 0=Sunday
const VALID_HOURS = new Set(Array.from({length: 24}, (_, i) => i));
const VALID_HOURS = new Set(Array.from({ length: 24 }, (_, i) => i));
interface BackupSettings {
enabled: boolean;
@@ -28,11 +29,16 @@ export function buildCronExpression(settings: BackupSettings): string {
const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
switch (settings.interval) {
case 'hourly': return '0 * * * *';
case 'daily': return `0 ${hour} * * *`;
case 'weekly': return `0 ${hour} * * ${dow}`;
case 'monthly': return `0 ${hour} ${dom} * *`;
default: return `0 ${hour} * * *`;
case 'hourly':
return '0 * * * *';
case 'daily':
return `0 ${hour} * * *`;
case 'weekly':
return `0 ${hour} * * ${dow}`;
case 'monthly':
return `0 ${hour} ${dom} * *`;
default:
return `0 ${hour} * * *`;
}
}
@@ -67,7 +73,10 @@ async function runBackup(): Promise<void> {
try {
// Flush WAL to main DB file before archiving
try { const { db } = require('./db/database'); db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try {
const { db } = require('./db/database');
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
} catch (e) {}
await new Promise<void>((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
@@ -104,7 +113,7 @@ function autoBackupTimestampMs(filename: string): number | null {
export function cleanupOldBackups(keepDays: number, now: number = Date.now()): void {
try {
const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
const files = fs.readdirSync(backupsDir).filter(f => f.startsWith('auto-backup-') && f.endsWith('.zip'));
const files = fs.readdirSync(backupsDir).filter((f) => f.startsWith('auto-backup-') && f.endsWith('.zip'));
for (const file of files) {
const filePath = path.join(backupsDir, file);
const ageMs = autoBackupTimestampMs(file) ?? fs.statSync(filePath).mtimeMs;
@@ -133,14 +142,19 @@ function start(): void {
const expression = buildCronExpression(settings);
const tz = process.env.TZ || 'UTC';
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
logInfo(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
logInfo(
`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`,
);
}
// Demo mode: hourly reset of demo user data
let demoTask: ScheduledTask | null = null;
function startDemoReset(): void {
if (demoTask) { demoTask.stop(); demoTask = null; }
if (demoTask) {
demoTask.stop();
demoTask = null;
}
if (process.env.DEMO_MODE?.toLowerCase() !== 'true') return;
demoTask = cron.schedule('0 * * * *', () => {
@@ -158,11 +172,15 @@ function startDemoReset(): void {
let reminderTask: ScheduledTask | null = null;
function startTripReminders(): void {
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
if (reminderTask) {
reminderTask.stop();
reminderTask = null;
}
try {
const { db } = require('./db/database');
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const getSetting = (key: string) =>
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
@@ -171,36 +189,58 @@ function startTripReminders(): void {
return;
}
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
logInfo(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
const tripCount = (
db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as {
c: number;
}
).c;
logInfo(
`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`,
);
} catch {
return;
}
const tz = process.env.TZ || 'UTC';
reminderTask = cron.schedule('0 9 * * *', async () => {
try {
const { db } = require('./db/database');
const { send } = require('./services/notificationService');
reminderTask = cron.schedule(
'0 9 * * *',
async () => {
try {
const { db } = require('./db/database');
const { send } = require('./services/notificationService');
const trips = db.prepare(`
const trips = db
.prepare(
`
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
WHERE t.reminder_days > 0
AND t.start_date IS NOT NULL
AND t.start_date = date('now', '+' || t.reminder_days || ' days')
`).all() as { id: number; title: string; user_id: number; reminder_days: number }[];
`,
)
.all() as { id: number; title: string; user_id: number; reminder_days: number }[];
for (const trip of trips) {
await send({ event: 'trip_reminder', actorId: null, scope: 'trip', targetId: trip.id, params: { trip: trip.title, tripId: String(trip.id) } }).catch(() => {});
}
for (const trip of trips) {
await send({
event: 'trip_reminder',
actorId: null,
scope: 'trip',
targetId: trip.id,
params: { trip: trip.title, tripId: String(trip.id) },
}).catch(() => {});
}
if (trips.length > 0) {
logInfo(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
if (trips.length > 0) {
logInfo(
`Trip reminders sent for ${trips.length} trip(s): ${trips.map((t) => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`,
);
}
} catch (err: unknown) {
logError(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
}
} catch (err: unknown) {
logError(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
},
{ timezone: tz },
);
}
// Todo due-date reminders: daily check at 9 AM for unchecked todos
@@ -212,10 +252,14 @@ const TODO_REMINDER_LEAD_DAYS = 3;
let todoReminderTask: ScheduledTask | null = null;
function startTodoReminders(): void {
if (todoReminderTask) { todoReminderTask.stop(); todoReminderTask = null; }
if (todoReminderTask) {
todoReminderTask.stop();
todoReminderTask = null;
}
const { db } = require('./db/database');
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const getSetting = (key: string) =>
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const enabled = getSetting('notify_todo_due') !== 'false';
if (!enabled) {
logInfo('Todo due reminders: disabled in settings');
@@ -224,14 +268,18 @@ function startTodoReminders(): void {
logInfo(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
const tz = process.env.TZ || 'UTC';
todoReminderTask = cron.schedule('0 9 * * *', async () => {
try {
const { send } = require('./services/notificationService');
todoReminderTask = cron.schedule(
'0 9 * * *',
async () => {
try {
const { send } = require('./services/notificationService');
// Select unchecked todos with a due date inside the lead window
// that haven't been reminded in the last 24 hours. `due_date` is
// stored as a YYYY-MM-DD text; SQLite date() handles it directly.
const todos = db.prepare(`
// Select unchecked todos with a due date inside the lead window
// that haven't been reminded in the last 24 hours. `due_date` is
// stored as a YYYY-MM-DD text; SQLite date() handles it directly.
const todos = db
.prepare(
`
SELECT ti.id, ti.trip_id, ti.name, ti.due_date, ti.assigned_user_id,
t.title AS trip_title, t.user_id AS trip_owner_id
FROM todo_items ti
@@ -242,87 +290,115 @@ function startTodoReminders(): void {
AND date(ti.due_date) <= date('now', '+' || ? || ' days')
AND date(ti.due_date) >= date('now')
AND (ti.reminded_at IS NULL OR ti.reminded_at <= datetime('now', '-20 hours'))
`).all(TODO_REMINDER_LEAD_DAYS) as {
id: number; trip_id: number; name: string; due_date: string;
assigned_user_id: number | null; trip_title: string; trip_owner_id: number;
}[];
`,
)
.all(TODO_REMINDER_LEAD_DAYS) as {
id: number;
trip_id: number;
name: string;
due_date: string;
assigned_user_id: number | null;
trip_title: string;
trip_owner_id: number;
}[];
for (const todo of todos) {
const targetScope: 'user' | 'trip' = todo.assigned_user_id ? 'user' : 'trip';
const targetId = todo.assigned_user_id ?? todo.trip_id;
await send({
event: 'todo_due',
actorId: null,
scope: targetScope,
targetId,
params: {
todo: todo.name,
trip: todo.trip_title,
tripId: String(todo.trip_id),
due: todo.due_date,
},
}).catch(() => {});
db.prepare('UPDATE todo_items SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?').run(todo.id);
}
for (const todo of todos) {
const targetScope: 'user' | 'trip' = todo.assigned_user_id ? 'user' : 'trip';
const targetId = todo.assigned_user_id ?? todo.trip_id;
await send({
event: 'todo_due',
actorId: null,
scope: targetScope,
targetId,
params: {
todo: todo.name,
trip: todo.trip_title,
tripId: String(todo.trip_id),
due: todo.due_date,
},
}).catch(() => {});
db.prepare('UPDATE todo_items SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?').run(todo.id);
}
if (todos.length > 0) {
logInfo(`Todo reminders sent for ${todos.length} item(s)`);
if (todos.length > 0) {
logInfo(`Todo reminders sent for ${todos.length} item(s)`);
}
} catch (err: unknown) {
logError(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
}
} catch (err: unknown) {
logError(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
},
{ timezone: tz },
);
}
// Version check: daily at 9 AM — notify admins if a new TREK release is available
let versionCheckTask: ScheduledTask | null = null;
function startVersionCheck(): void {
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
if (versionCheckTask) {
versionCheckTask.stop();
versionCheckTask = null;
}
const tz = process.env.TZ || 'UTC';
versionCheckTask = cron.schedule('0 9 * * *', async () => {
try {
const { checkAndNotifyVersion } = require('./services/adminService');
await checkAndNotifyVersion();
} catch (err: unknown) {
logError(`Version check: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
versionCheckTask = cron.schedule(
'0 9 * * *',
async () => {
try {
const { checkAndNotifyVersion } = require('./services/adminService');
await checkAndNotifyVersion();
} catch (err: unknown) {
logError(`Version check: ${err instanceof Error ? err.message : err}`);
}
},
{ timezone: tz },
);
}
// Idempotency key cleanup: nightly at 3 AM — delete keys older than 24 hours
let idempotencyCleanupTask: ScheduledTask | null = null;
function startIdempotencyCleanup(): void {
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
if (idempotencyCleanupTask) {
idempotencyCleanupTask.stop();
idempotencyCleanupTask = null;
}
const tz = process.env.TZ || 'UTC';
idempotencyCleanupTask = cron.schedule('0 3 * * *', () => {
try {
const { db } = require('./db/database');
const cutoff = Math.floor(Date.now() / 1000) - 86400;
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
if (result.changes > 0) {
logInfo(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
idempotencyCleanupTask = cron.schedule(
'0 3 * * *',
() => {
try {
const { db } = require('./db/database');
const cutoff = Math.floor(Date.now() / 1000) - 86400;
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
if (result.changes > 0) {
logInfo(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
}
} catch (err: unknown) {
logError(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
}
} catch (err: unknown) {
logError(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
},
{ timezone: tz },
);
}
// Trek photo cache cleanup: every 2 hours — evict disk files and DB rows past their 1h TTL
let trekPhotoCacheTask: ScheduledTask | null = null;
function startTrekPhotoCacheCleanup(): void {
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
if (trekPhotoCacheTask) {
trekPhotoCacheTask.stop();
trekPhotoCacheTask = null;
}
// Run once immediately on startup to evict any entries left over from a previous run
try {
const { sweepExpired } = require('./services/memories/trekPhotoCache');
sweepExpired();
} catch { /* cache dir may not exist yet — harmless */ }
} catch {
/* cache dir may not exist yet — harmless */
}
trekPhotoCacheTask = cron.schedule('0 */2 * * *', () => {
try {
@@ -335,12 +411,42 @@ function startTrekPhotoCacheCleanup(): void {
}
function stop(): void {
if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = null; }
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
if (currentTask) {
currentTask.stop();
currentTask = null;
}
if (demoTask) {
demoTask.stop();
demoTask = null;
}
if (reminderTask) {
reminderTask.stop();
reminderTask = null;
}
if (versionCheckTask) {
versionCheckTask.stop();
versionCheckTask = null;
}
if (idempotencyCleanupTask) {
idempotencyCleanupTask.stop();
idempotencyCleanupTask = null;
}
if (trekPhotoCacheTask) {
trekPhotoCacheTask.stop();
trekPhotoCacheTask = null;
}
}
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
export {
start,
stop,
startDemoReset,
startTripReminders,
startTodoReminders,
startVersionCheck,
startIdempotencyCleanup,
startTrekPhotoCacheCleanup,
loadSettings,
saveSettings,
VALID_INTERVALS,
};
+289 -126
View File
@@ -1,18 +1,19 @@
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
import { db } from '../db/database';
import { User, Addon } from '../types';
import { updateJwtSecret } from '../config';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { db } from '../db/database';
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
import { deleteUserCompletely } from './userCleanupService';
import { validatePassword } from './passwordPolicy';
import { User, Addon } from '../types';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { resolveAuthToggles } from './authService';
import { getPhotoProviderConfig } from './memories/helpersService';
import { send as sendNotification } from './notificationService';
import { resolveAuthToggles } from './authService';
import { validatePassword } from './passwordPolicy';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { deleteUserCompletely } from './userCleanupService';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -29,9 +30,11 @@ export function compareVersions(a: string, b: string): number {
const preN = n !== null && Number.isFinite(n) ? n : null;
return { parts, preN };
};
const pa = parse(a), pb = parse(b);
const pa = parse(a),
pb = parse(b);
for (let i = 0; i < Math.max(pa.parts.length, pb.parts.length); i++) {
const na = pa.parts[i] || 0, nb = pb.parts[i] || 0;
const na = pa.parts[i] || 0,
nb = pb.parts[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
@@ -47,26 +50,37 @@ export function compareVersions(a: string, b: string): number {
export const isDocker = (() => {
try {
return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'));
} catch { return false; }
return (
fs.existsSync('/.dockerenv') ||
(fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'))
);
} catch {
return false;
}
})();
// ── User CRUD ──────────────────────────────────────────────────────────────
export function listUsers() {
const users = db.prepare(
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
).all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & { avatar?: string | null })[];
const users = db
.prepare(
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users ORDER BY created_at DESC',
)
.all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & {
avatar?: string | null;
})[];
let onlineUserIds = new Set<number>();
try {
const { getOnlineUserIds } = require('../websocket');
onlineUserIds = getOnlineUserIds();
} catch { /* */ }
return users.map(u => ({
} catch {
/* */
}
return users.map((u) => ({
...u,
avatar_url: u.avatar ? `/uploads/avatars/${u.avatar}` : null,
created_at: utcSuffix(u.created_at),
updated_at: utcSuffix(u.updated_at as string),
updated_at: utcSuffix(u.updated_at),
last_login: utcSuffix(u.last_login),
online: onlineUserIds.has(u.id),
}));
@@ -96,13 +110,13 @@ export function createUser(data: { username: string; email: string; password: st
const passwordHash = bcrypt.hashSync(password, 12);
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
).run(username, email, passwordHash, data.role || 'user');
const result = db
.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
.run(username, email, passwordHash, data.role || 'user');
const user = db.prepare(
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(result.lastInsertRowid);
const user = db
.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?')
.get(result.lastInsertRowid);
return {
user,
@@ -138,7 +152,8 @@ export function updateUser(id: string, data: { username?: string; email?: string
}
const passwordHash = password ? bcrypt.hashSync(password, 12) : null;
db.prepare(`
db.prepare(
`
UPDATE users SET
username = COALESCE(?, username),
email = COALESCE(?, email),
@@ -146,11 +161,12 @@ export function updateUser(id: string, data: { username?: string; email?: string
password_hash = COALESCE(?, password_hash),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(username || null, email || null, role || null, passwordHash, id);
`,
).run(username || null, email || null, role || null, passwordHash, id);
const updated = db.prepare(
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(id);
const updated = db
.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?')
.get(id);
const changed: string[] = [];
if (username) changed.push('username');
@@ -170,7 +186,9 @@ export function deleteUser(id: string, currentUserId: number) {
return { error: 'Cannot delete own account', status: 400 };
}
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined;
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as
| { id: number; email: string }
| undefined;
if (!userToDel) return { error: 'User not found', status: 404 };
deleteUserCompletely(userToDel.id);
@@ -191,7 +209,7 @@ export function getStats() {
export function getPermissions() {
const current = getAllPermissions();
const actions = PERMISSION_ACTIONS.map(a => ({
const actions = PERMISSION_ACTIONS.map((a) => ({
key: a.key,
level: current[a.key],
defaultLevel: a.defaultLevel,
@@ -225,13 +243,17 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
ip: string | null;
};
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.id DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as Row[];
`,
)
.all(limit, offset) as Row[];
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
@@ -244,7 +266,8 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
details = { _parse_error: true };
}
}
const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
const created_at =
r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
return { ...r, created_at, details };
});
@@ -254,7 +277,8 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
// ── OIDC Settings ──────────────────────────────────────────────────────────
export function getOidcSettings() {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
const get = (key: string) =>
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || '';
const secret = decrypt_api_key(get('oidc_client_secret'));
return {
issuer: get('oidc_issuer'),
@@ -275,10 +299,14 @@ export function updateOidcSettings(data: {
}): { error?: string; status?: number; success?: boolean } {
// Lockout prevention: can't remove OIDC config when password login is disabled
if ((data.issuer === '' || data.client_id === '') && !resolveAuthToggles().password_login) {
return { error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.', status: 400 };
return {
error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.',
status: 400,
};
}
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
const set = (key: string, val: string) =>
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, val || '');
set('oidc_issuer', data.issuer ?? '');
set('oidc_client_id', data.client_id ?? '');
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
@@ -307,10 +335,9 @@ export function saveDemoBaseline(): { error?: string; status?: number; message?:
export async function getGithubReleases(perPage: string = '10', page: string = '1') {
try {
const resp = await fetch(
`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${perPage}&page=${page}`,
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
const resp = await fetch(`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${perPage}&page=${page}`, {
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
});
if (!resp.ok) return [];
const data = await resp.json();
return Array.isArray(data) ? data : [];
@@ -343,41 +370,59 @@ export async function checkVersion(): Promise<VersionInfo> {
const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version;
const isPrerelease = currentVersion.includes('-pre.');
const fallback: VersionInfo = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease };
const fallback: VersionInfo = {
current: currentVersion,
latest: currentVersion,
update_available: false,
is_docker: isDocker,
is_prerelease: isPrerelease,
};
let result: VersionInfo;
try {
if (isPrerelease) {
// Fetch release list and find the newest prerelease
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=100',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
const resp = await fetch('https://api.github.com/repos/mauriceboe/TREK/releases?per_page=100', {
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
});
if (!resp.ok) {
return fallback;
}
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
const data = (await resp.json()) as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
const prereleases = Array.isArray(data) ? data.filter((r) => r.prerelease) : [];
if (!prereleases.length) {
return fallback;
}
// Pre-compute stripped versions, then sort descending
const tagged = prereleases.map(r => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
const tagged = prereleases.map((r) => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
tagged.sort((a, b) => compareVersions(b.v, a.v));
const latest = tagged[0].v;
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: tagged[0].r.html_url || '', is_docker: isDocker, is_prerelease: true };
result = {
current: currentVersion,
latest,
update_available,
release_url: tagged[0].r.html_url || '',
is_docker: isDocker,
is_prerelease: true,
};
} else {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
const resp = await fetch('https://api.github.com/repos/mauriceboe/TREK/releases/latest', {
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
});
if (!resp.ok) {
return fallback;
}
const data = await resp.json() as { tag_name?: string; html_url?: string };
const data = (await resp.json()) as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
result = {
current: currentVersion,
latest,
update_available,
release_url: data.html_url || '',
is_docker: isDocker,
is_prerelease: false,
};
}
} catch {
return fallback;
@@ -392,10 +437,17 @@ export async function checkAndNotifyVersion(): Promise<void> {
const result = await checkVersion();
if (!result.update_available) return;
const lastNotified = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
const lastNotified = (
db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as
| { value: string }
| undefined
)?.value;
if (lastNotified === result.latest) return;
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', result.latest);
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
'last_notified_version',
result.latest,
);
await sendNotification({
event: 'version_available',
@@ -412,15 +464,22 @@ export async function checkAndNotifyVersion(): Promise<void> {
// ── Invite Tokens ──────────────────────────────────────────────────────────
export function listInvites() {
return db.prepare(`
return db
.prepare(
`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
ORDER BY i.created_at DESC
`).all();
`,
)
.all();
}
export function createInvite(createdBy: number, data: { max_uses?: string | number; expires_in_days?: string | number }) {
export function createInvite(
createdBy: number,
data: { max_uses?: string | number; expires_in_days?: string | number },
) {
const rawUses = parseInt(String(data.max_uses));
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
const token = crypto.randomBytes(16).toString('hex');
@@ -428,17 +487,21 @@ export function createInvite(createdBy: number, data: { max_uses?: string | numb
? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString()
: null;
const ins = db.prepare(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
).run(token, uses, expiresAt, createdBy);
const ins = db
.prepare('INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)')
.run(token, uses, expiresAt, createdBy);
const inviteId = Number(ins.lastInsertRowid);
const invite = db.prepare(`
const invite = db
.prepare(
`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
WHERE i.id = ?
`).get(inviteId);
`,
)
.get(inviteId);
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null };
}
@@ -453,57 +516,82 @@ export function deleteInvite(id: string) {
// ── Bag Tracking ───────────────────────────────────────────────────────────
export function getBagTracking() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value === 'true' };
}
export function updateBagTracking(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Places Photos ─────────────────────────────────────────────────────────
export function getPlacesPhotos() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesPhotos(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Places Autocomplete ────────────────────────────────────────────────────
export function getPlacesAutocomplete() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesAutocomplete(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Places Details ─────────────────────────────────────────────────────────
export function getPlacesDetails() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesDetails(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Collab Features ───────────────────────────────────────────────────────
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
const COLLAB_FEATURE_KEYS = [
'collab_chat_enabled',
'collab_notes_enabled',
'collab_polls_enabled',
'collab_whatsnext_enabled',
] as const;
export function getCollabFeatures() {
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").all() as { key: string; value: string }[];
const rows = db
.prepare(
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
)
.all() as { key: string; value: string }[];
const map: Record<string, string> = {};
for (const r of rows) map[r.key] = r.value;
return {
@@ -514,9 +602,19 @@ export function getCollabFeatures() {
};
}
export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
const mapping: Record<string, string> = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' };
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
export function updateCollabFeatures(features: {
chat?: boolean;
notes?: boolean;
polls?: boolean;
whatsnext?: boolean;
}) {
const mapping: Record<string, string> = {
chat: 'collab_chat_enabled',
notes: 'collab_notes_enabled',
polls: 'collab_polls_enabled',
whatsnext: 'collab_whatsnext_enabled',
};
const stmt = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
for (const [feat, key] of Object.entries(mapping)) {
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
}
@@ -526,31 +624,43 @@ export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean
// ── Packing Templates ──────────────────────────────────────────────────────
export function listPackingTemplates() {
return db.prepare(`
return db
.prepare(
`
SELECT pt.*, u.username as created_by_name,
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
(SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count
FROM packing_templates pt
JOIN users u ON pt.created_by = u.id
ORDER BY pt.created_at DESC
`).all();
`,
)
.all();
}
export function getPackingTemplate(id: string) {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id);
if (!template) return { error: 'Template not found', status: 404 };
const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(id) as any[];
const items = db.prepare(`
const categories = db
.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id')
.all(id) as any[];
const items = db
.prepare(
`
SELECT ti.* FROM packing_template_items ti
JOIN packing_template_categories tc ON ti.category_id = tc.id
WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id
`).all(id);
`,
)
.all(id);
return { template, categories, items };
}
export function createPackingTemplate(name: string, createdBy: number) {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), createdBy);
const result = db
.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)')
.run(name.trim(), createdBy);
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid);
return { template };
}
@@ -575,20 +685,29 @@ export function createTemplateCategory(templateId: string, name: string) {
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(templateId);
if (!template) return { error: 'Template not found', status: 404 };
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(templateId) as { max: number | null };
const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, name.trim(), (maxOrder.max ?? -1) + 1);
const maxOrder = db
.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?')
.get(templateId) as { max: number | null };
const result = db
.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)')
.run(templateId, name.trim(), (maxOrder.max ?? -1) + 1);
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) };
}
export function updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
const cat = db
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
.get(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
if (data.name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId);
if (data.name?.trim())
db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId);
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(catId) };
}
export function deleteTemplateCategory(templateId: string, catId: string) {
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
const cat = db
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
.get(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(catId);
return {};
@@ -598,17 +717,24 @@ export function deleteTemplateCategory(templateId: string, catId: string) {
export function createTemplateItem(templateId: string, catId: string, name: string) {
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
const cat = db
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
.get(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(catId) as { max: number | null };
const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, name.trim(), (maxOrder.max ?? -1) + 1);
const maxOrder = db
.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?')
.get(catId) as { max: number | null };
const result = db
.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)')
.run(catId, name.trim(), (maxOrder.max ?? -1) + 1);
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) };
}
export function updateTemplateItem(itemId: string, data: { name?: string }) {
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId);
if (!item) return { error: 'Item not found', status: 404 };
if (data.name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId);
if (data.name?.trim())
db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId);
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId) };
}
@@ -628,16 +754,31 @@ export function isAddonEnabled(addonId: string): boolean {
export function listAddons() {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
const providers = db.prepare(`
const providers = db
.prepare(
`
SELECT id, name, description, icon, enabled, sort_order
FROM photo_providers
ORDER BY sort_order, id
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }>;
const fields = db.prepare(`
`,
)
.all() as Array<{
id: string;
name: string;
description?: string | null;
icon: string;
enabled: number;
sort_order: number;
}>;
const fields = db
.prepare(
`
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
ORDER BY sort_order, id
`).all() as Array<{
`,
)
.all() as Array<{
provider_id: string;
field_key: string;
label: string;
@@ -657,8 +798,8 @@ export function listAddons() {
}
return [
...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })),
...providers.map(p => ({
...addons.map((a) => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })),
...providers.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
@@ -666,7 +807,7 @@ export function listAddons() {
icon: p.icon,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
@@ -684,52 +825,68 @@ export function listAddons() {
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as
| { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }
| undefined;
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
if (addon) {
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
if (data.enabled !== undefined)
db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
if (data.config !== undefined)
db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
} else {
if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
if (data.enabled !== undefined)
db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
}
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as
| { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }
| undefined;
const updated = updatedAddon
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
: updatedProvider
? {
id: updatedProvider.id,
name: updatedProvider.name,
description: updatedProvider.description,
type: 'photo_provider',
icon: updatedProvider.icon,
enabled: !!updatedProvider.enabled,
config: getPhotoProviderConfig(updatedProvider.id),
sort_order: updatedProvider.sort_order,
}
id: updatedProvider.id,
name: updatedProvider.name,
description: updatedProvider.description,
type: 'photo_provider',
icon: updatedProvider.icon,
enabled: !!updatedProvider.enabled,
config: getPhotoProviderConfig(updatedProvider.id),
sort_order: updatedProvider.sort_order,
}
: null;
return {
addon: updated,
auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined },
auditDetails: {
enabled: data.enabled !== undefined ? !!data.enabled : undefined,
config_changed: data.config !== undefined,
},
};
}
// ── MCP Tokens ─────────────────────────────────────────────────────────────
export function listMcpTokens() {
return db.prepare(`
return db
.prepare(
`
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
FROM mcp_tokens t
JOIN users u ON u.id = t.user_id
ORDER BY t.created_at DESC
`).all();
`,
)
.all();
}
export function deleteMcpToken(id: string) {
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as { id: number; user_id: number } | undefined;
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as
| { id: number; user_id: number }
| undefined;
if (!token) return { error: 'Token not found', status: 404 };
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
revokeUserSessions(token.user_id);
@@ -739,7 +896,9 @@ export function deleteMcpToken(id: string) {
// ── OAuth Sessions ─────────────────────────────────────────────────────────
export function listOAuthSessions() {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
@@ -748,12 +907,16 @@ export function listOAuthSessions() {
WHERE ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`).all() as (Record<string, unknown> & { scopes: string })[];
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes) }));
`,
)
.all() as (Record<string, unknown> & { scopes: string })[];
return rows.map((r) => ({ ...r, scopes: JSON.parse(r.scopes) }));
}
export function revokeOAuthSession(id: string) {
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as { id: number; user_id: number; client_id: string } | undefined;
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as
| { id: number; user_id: number; client_id: string }
| undefined;
if (!row) return { error: 'Session not found', status: 404 };
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
revokeUserSessionsForClient(row.user_id, row.client_id);
+58 -12
View File
@@ -1,6 +1,7 @@
import { db } from '../db/database';
import fs from 'node:fs';
import path from 'node:path';
import { db } from '../db/database';
export interface Airport {
iata: string;
@@ -27,13 +28,13 @@ function load(): Airport[] {
}
const raw = fs.readFileSync(file, 'utf8');
cache = JSON.parse(raw) as Airport[];
byIata = new Map(cache.map(a => [a.iata, a]));
byIata = new Map(cache.map((a) => [a.iata, a]));
return cache;
}
export function findByIata(code: string): Airport | null {
load();
return byIata!.get(code.toUpperCase()) ?? null;
return byIata.get(code.toUpperCase()) ?? null;
}
export function searchAirports(query: string, limit = 12): Airport[] {
@@ -43,7 +44,7 @@ export function searchAirports(query: string, limit = 12): Airport[] {
const upper = q.toUpperCase();
if (q.length === 3) {
const exact = byIata!.get(upper);
const exact = byIata.get(upper);
if (exact) return [exact];
}
@@ -60,16 +61,25 @@ export function searchAirports(query: string, limit = 12): Airport[] {
if (score > 0) matches.push({ a, score });
}
matches.sort((x, y) => y.score - x.score || x.a.iata.localeCompare(y.a.iata));
return matches.slice(0, limit).map(m => m.a);
return matches.slice(0, limit).map((m) => m.a);
}
export function backfillFlightEndpoints(): void {
const pending = db.prepare(`
const pending = db
.prepare(
`
SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time
FROM reservations r
WHERE r.type = 'flight'
AND NOT EXISTS (SELECT 1 FROM reservation_endpoints e WHERE e.reservation_id = r.id)
`).all() as { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[];
`,
)
.all() as {
id: number;
metadata: string | null;
reservation_time: string | null;
reservation_end_time: string | null;
}[];
if (pending.length === 0) return;
@@ -83,14 +93,28 @@ export function backfillFlightEndpoints(): void {
let filled = 0;
let flagged = 0;
for (const r of pending) {
if (!r.metadata) { markReview.run(r.id); flagged++; continue; }
if (!r.metadata) {
markReview.run(r.id);
flagged++;
continue;
}
let meta: any;
try { meta = JSON.parse(r.metadata); } catch { markReview.run(r.id); flagged++; continue; }
try {
meta = JSON.parse(r.metadata);
} catch {
markReview.run(r.id);
flagged++;
continue;
}
const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null;
const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null;
if (!dep || !arr) { markReview.run(r.id); flagged++; continue; }
if (!dep || !arr) {
markReview.run(r.id);
flagged++;
continue;
}
const split = (iso: string | null) => {
if (!iso) return { date: null as string | null, time: null as string | null };
@@ -100,8 +124,30 @@ export function backfillFlightEndpoints(): void {
const depParts = split(r.reservation_time);
const arrParts = split(r.reservation_end_time);
insert.run(r.id, 'from', 0, dep.city ? `${dep.city} (${dep.iata})` : dep.name, dep.iata, dep.lat, dep.lng, dep.tz, depParts.time, depParts.date);
insert.run(r.id, 'to', 1, arr.city ? `${arr.city} (${arr.iata})` : arr.name, arr.iata, arr.lat, arr.lng, arr.tz, arrParts.time, arrParts.date);
insert.run(
r.id,
'from',
0,
dep.city ? `${dep.city} (${dep.iata})` : dep.name,
dep.iata,
dep.lat,
dep.lng,
dep.tz,
depParts.time,
depParts.date,
);
insert.run(
r.id,
'to',
1,
arr.city ? `${arr.city} (${arr.iata})` : arr.name,
arr.iata,
arr.lat,
arr.lng,
arr.tz,
arrParts.time,
arrParts.date,
);
filled++;
}
+2 -2
View File
@@ -1,6 +1,7 @@
import * as crypto from 'node:crypto';
import { ENCRYPTION_KEY } from '../config';
import * as crypto from 'node:crypto';
const ENCRYPTED_PREFIX = 'enc:v1:';
function get_key() {
@@ -40,4 +41,3 @@ export function maybe_encrypt_api_key(value: unknown) {
if (trimmed.startsWith(ENCRYPTED_PREFIX)) return trimmed;
return encrypt_api_key(trimmed);
}
+88 -43
View File
@@ -1,9 +1,11 @@
import { db } from '../db/database';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers';
import { AssignmentRow, DayAssignment } from '../types';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers';
export function getAssignmentWithPlace(assignmentId: number | bigint) {
const a = db.prepare(`
const a = db
.prepare(
`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
@@ -15,22 +17,32 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.id = ?
`).get(assignmentId) as AssignmentRow | undefined;
`,
)
.get(assignmentId) as AssignmentRow | undefined;
if (!a) return null;
const tags = db.prepare(`
const tags = db
.prepare(
`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(a.place_id);
`,
)
.all(a.place_id);
const participants = db.prepare(`
const participants = db
.prepare(
`
SELECT ap.user_id, u.username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(a.id);
`,
)
.all(a.id);
return {
id: a.id,
@@ -61,19 +73,23 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
category: a.category_id
? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
}
: null,
tags,
}
},
};
}
export function listDayAssignments(dayId: string | number) {
const assignments = db.prepare(`
const assignments = db
.prepare(
`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
@@ -86,15 +102,17 @@ export function listDayAssignments(dayId: string | number) {
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId) as AssignmentRow[];
`,
)
.all(dayId) as AssignmentRow[];
const placeIds = [...new Set(assignments.map(a => a.place_id))];
const placeIds = [...new Set(assignments.map((a) => a.place_id))];
const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true });
const assignmentIds = assignments.map(a => a.id);
const assignmentIds = assignments.map((a) => a.id);
const participantsByAssignment = loadParticipantsByAssignmentIds(assignmentIds);
return assignments.map(a => {
return assignments.map((a) => {
return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []);
});
}
@@ -108,20 +126,24 @@ export function placeExists(placeId: string | number, tripId: string | number) {
}
export function createAssignment(dayId: string | number, placeId: string | number, notes: string | null) {
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as {
max: number | null;
};
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
).run(dayId, placeId, orderIndex, notes || null);
const result = db
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)')
.run(dayId, placeId, orderIndex, notes || null);
return getAssignmentWithPlace(result.lastInsertRowid);
}
export function assignmentExistsInDay(id: string | number, dayId: string | number, tripId: string | number) {
return !!db.prepare(
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
).get(id, dayId, tripId);
return !!db
.prepare(
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?',
)
.get(id, dayId, tripId);
}
export function deleteAssignment(id: string | number) {
@@ -143,11 +165,15 @@ export function reorderAssignments(dayId: string | number, orderedIds: number[])
}
export function getAssignmentForTrip(id: string | number, tripId: string | number) {
return db.prepare(`
return db
.prepare(
`
SELECT da.* FROM day_assignments da
JOIN days d ON da.day_id = d.id
WHERE da.id = ? AND d.trip_id = ?
`).get(id, tripId) as DayAssignment | undefined;
`,
)
.get(id, tripId) as DayAssignment | undefined;
}
export function moveAssignment(id: string | number, newDayId: string | number, orderIndex: number, oldDayId: number) {
@@ -157,37 +183,52 @@ export function moveAssignment(id: string | number, newDayId: string | number, o
}
export function getParticipants(assignmentId: string | number) {
return db.prepare(`
return db
.prepare(
`
SELECT ap.user_id, u.username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(assignmentId);
`,
)
.all(assignmentId);
}
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
.run(placeTime ?? null, endTime ?? null, id);
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run(
placeTime ?? null,
endTime ?? null,
id,
);
// Auto-sort: reorder timed assignments chronologically within the day
if (placeTime) {
const assignment = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id) as { day_id: number } | undefined;
const assignment = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id) as
| { day_id: number }
| undefined;
if (assignment) {
const dayAssignments = db.prepare(`
const dayAssignments = db
.prepare(
`
SELECT da.id, COALESCE(da.assignment_time, p.place_time) as effective_time
FROM day_assignments da
JOIN places p ON da.place_id = p.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC
`).all(assignment.day_id) as { id: number; effective_time: string | null }[];
`,
)
.all(assignment.day_id) as { id: number; effective_time: string | null }[];
// Separate timed and untimed, sort timed by time
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
return ta.localeCompare(tb);
});
const untimed = dayAssignments.filter(a => !a.effective_time);
const timed = dayAssignments
.filter((a) => a.effective_time)
.sort((a, b) => {
const ta = a.effective_time.includes(':') ? a.effective_time : '99:99';
const tb = b.effective_time.includes(':') ? b.effective_time : '99:99';
return ta.localeCompare(tb);
});
const untimed = dayAssignments.filter((a) => !a.effective_time);
// Interleave: timed in chronological order, untimed keep relative position
const reordered = [...timed, ...untimed];
@@ -206,10 +247,14 @@ export function setParticipants(assignmentId: string | number, userIds: number[]
for (const userId of userIds) insert.run(assignmentId, userId);
}
return db.prepare(`
return db
.prepare(
`
SELECT ap.user_id, u.username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(assignmentId);
`,
)
.all(assignmentId);
}
File diff suppressed because it is too large Load Diff
+20 -11
View File
@@ -1,5 +1,6 @@
import { Request } from 'express';
import { db } from '../db/database';
import { Request } from 'express';
import fs from 'fs';
import path from 'path';
@@ -8,17 +9,19 @@ const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_LOG_FILES = 5;
const C = {
blue: '\x1b[34m',
cyan: '\x1b[36m',
red: '\x1b[31m',
yellow: '\x1b[33m',
reset: '\x1b[0m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
red: '\x1b[31m',
yellow: '\x1b[33m',
reset: '\x1b[0m',
};
// ── File logger with rotation ─────────────────────────────────────────────
const logsDir = path.join(process.cwd(), 'data/logs');
try { fs.mkdirSync(logsDir, { recursive: true }); } catch {}
try {
fs.mkdirSync(logsDir, { recursive: true });
} catch {}
const logFilePath = path.join(logsDir, 'trek.log');
function rotateIfNeeded(): void {
@@ -91,7 +94,9 @@ function resolveUserEmail(userId: number | null): string {
try {
const row = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
return row?.email || `uid:${userId}`;
} catch { return `uid:${userId}`; }
} catch {
return `uid:${userId}`;
}
}
const ACTION_LABELS: Record<string, string> = {
@@ -122,9 +127,13 @@ export function writeAudit(entry: {
}): void {
try {
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
db.prepare(
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`
).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null);
db.prepare(`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`).run(
entry.userId,
entry.action,
entry.resource ?? null,
detailsJson,
entry.ip ?? null,
);
const email = resolveUserEmail(entry.userId);
const label = ACTION_LABELS[entry.action] || entry.action;
File diff suppressed because it is too large Load Diff
+41 -22
View File
@@ -1,12 +1,13 @@
import archiver from 'archiver';
import unzipper from 'unzipper';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
import { db, closeDb, reinitialize } from '../db/database';
import * as scheduler from '../scheduler';
import { invalidatePermissionsCache } from './permissions';
import archiver from 'archiver';
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import unzipper from 'unzipper';
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
@@ -51,9 +52,7 @@ export function parseAutoBackupBody(body: Record<string, unknown>): {
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
const rawInterval = body.interval;
const interval =
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
? rawInterval
: 'daily';
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval) ? rawInterval : 'daily';
const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
@@ -109,9 +108,10 @@ export interface BackupInfo {
export function listBackups(): BackupInfo[] {
ensureBackupsDir();
return fs.readdirSync(backupsDir)
.filter(f => f.endsWith('.zip'))
.map(filename => {
return fs
.readdirSync(backupsDir)
.filter((f) => f.endsWith('.zip'))
.map((filename) => {
const filePath = path.join(backupsDir, filename);
const stat = fs.statSync(filePath);
return {
@@ -136,7 +136,9 @@ export async function createBackup(): Promise<BackupInfo> {
const outputPath = path.join(backupsDir, filename);
try {
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try {
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
} catch (e) {}
await new Promise<void>((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
@@ -186,7 +188,8 @@ export interface RestoreResult {
export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
try {
await fs.createReadStream(zipPath)
await fs
.createReadStream(zipPath)
.pipe(unzipper.Extract({ path: extractDir }))
.promise();
@@ -203,18 +206,26 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
if (integrityResult.integrity_check !== 'ok') {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: `Uploaded database failed integrity check: ${integrityResult.integrity_check}`, status: 400 };
return {
success: false,
error: `Uploaded database failed integrity check: ${integrityResult.integrity_check}`,
status: 400,
};
}
const requiredTables = ['users', 'trips', 'trip_members', 'places', 'days'];
const existingTables = uploadedDb
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[];
const tableNames = new Set(existingTables.map(t => t.name));
const existingTables = uploadedDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as {
name: string;
}[];
const tableNames = new Set(existingTables.map((t) => t.name));
for (const table of requiredTables) {
if (!tableNames.has(table)) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.`, status: 400 };
return {
success: false,
error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.`,
status: 400,
};
}
}
} catch (err) {
@@ -229,7 +240,9 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
try {
const dbDest = path.join(dataDir, 'travel.db');
for (const ext of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
try {
fs.unlinkSync(dbDest + ext);
} catch (e) {}
}
fs.copyFileSync(extractedDb, dbDest);
@@ -239,7 +252,9 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
const subPath = path.join(uploadsDir, sub);
if (fs.statSync(subPath).isDirectory()) {
for (const file of fs.readdirSync(subPath)) {
try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {}
try {
fs.unlinkSync(path.join(subPath, file));
} catch (e) {}
}
}
}
@@ -266,7 +281,11 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
// stale anyway. Invalidating here too costs nothing and guarantees
// we never serve cached permissions that don't match the DB state
// we leave the process in after a failed restore.
try { invalidatePermissionsCache(); } catch { /* best-effort */ }
try {
invalidatePermissionsCache();
} catch {
/* best-effort */
}
throw err;
}
}
+163 -66
View File
@@ -14,13 +14,17 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
}
function loadItemMembers(itemId: number | string) {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ?
`).all(itemId) as BudgetItemMember[];
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
`,
)
.all(itemId) as BudgetItemMember[];
return rows.map((m) => ({ ...m, avatar_url: avatarUrl(m) }));
}
// ---------------------------------------------------------------------------
@@ -28,70 +32,103 @@ function loadItemMembers(itemId: number | string) {
// ---------------------------------------------------------------------------
export function listBudgetItems(tripId: string | number) {
const items = db.prepare(`
const items = db
.prepare(
`
SELECT bi.* FROM budget_items bi
LEFT JOIN budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = bi.category
WHERE bi.trip_id = ?
ORDER BY COALESCE(bco.sort_order, 999999) ASC, bi.sort_order ASC
`).all(tripId) as BudgetItem[];
`,
)
.all(tripId) as BudgetItem[];
const itemIds = items.map(i => i.id);
const itemIds = items.map((i) => i.id);
const membersByItem: Record<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};
if (itemIds.length > 0) {
const allMembers = db.prepare(`
const allMembers = db
.prepare(
`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
`).all(...itemIds) as (BudgetItemMember & { budget_item_id: number })[];
`,
)
.all(...itemIds) as (BudgetItemMember & { budget_item_id: number })[];
for (const m of allMembers) {
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
membersByItem[m.budget_item_id].push({
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m),
user_id: m.user_id,
paid: m.paid,
username: m.username,
avatar_url: avatarUrl(m),
});
}
}
items.forEach(item => { item.members = membersByItem[item.id] || []; });
items.forEach((item) => {
item.members = membersByItem[item.id] || [];
});
return items;
}
export function createBudgetItem(
tripId: string | number,
data: { category?: string; name: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
data: {
category?: string;
name: string;
total_price?: number;
persons?: number | null;
days?: number | null;
note?: string | null;
expense_date?: string | null;
},
) {
const maxOrder = db.prepare(
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
).get(tripId) as { max: number | null };
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId) as {
max: number | null;
};
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const cat = data.category || 'Other';
// Ensure category has a sort_order entry
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
const catExists = db
.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?')
.get(tripId, cat);
if (!catExists) {
const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
const maxCatOrder = db
.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?')
.get(tripId) as { max: number | null };
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(
tripId,
cat,
catOrder,
);
}
const result = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
tripId,
cat,
data.name,
data.total_price || 0,
data.persons != null ? data.persons : null,
data.days !== undefined && data.days !== null ? data.days : null,
data.note || null,
sortOrder,
data.expense_date || null,
);
const result = db
.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
)
.run(
tripId,
cat,
data.name,
data.total_price || 0,
data.persons != null ? data.persons : null,
data.days !== undefined && data.days !== null ? data.days : null,
data.note || null,
sortOrder,
data.expense_date || null,
);
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] };
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & {
members?: BudgetItemMember[];
};
item.members = [];
return item;
}
@@ -110,12 +147,22 @@ export function linkBudgetItemToReservation(
export function updateBudgetItem(
id: string | number,
tripId: string | number,
data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null },
data: {
category?: string;
name?: string;
total_price?: number;
persons?: number | null;
days?: number | null;
note?: string | null;
sort_order?: number;
expense_date?: string | null;
},
) {
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
db.prepare(`
db.prepare(
`
UPDATE budget_items SET
category = COALESCE(?, category),
name = COALESCE(?, name),
@@ -126,29 +173,46 @@ export function updateBudgetItem(
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END,
expense_date = CASE WHEN ? THEN ? ELSE expense_date END
WHERE id = ?
`).run(
`,
).run(
data.category || null,
data.name || null,
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0,
data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null,
data.total_price !== undefined ? 1 : null,
data.total_price !== undefined ? data.total_price : 0,
data.persons !== undefined ? 1 : null,
data.persons !== undefined ? data.persons : null,
data.days !== undefined ? 1 : 0,
data.days !== undefined ? data.days : null,
data.note !== undefined ? 1 : 0,
data.note !== undefined ? data.note : null,
data.sort_order !== undefined ? 1 : null,
data.sort_order !== undefined ? data.sort_order : 0,
data.expense_date !== undefined ? 1 : 0,
data.expense_date !== undefined ? data.expense_date || null : null,
id,
);
// If category changed, update category order table
if (data.category) {
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
const catExists = db
.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?')
.get(tripId, data.category);
if (!catExists) {
const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
const maxCatOrder = db
.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?')
.get(tripId) as { max: number | null };
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, data.category, catOrder);
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(
tripId,
data.category,
catOrder,
);
}
}
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & {
members?: BudgetItemMember[];
};
updated.members = loadItemMembers(id);
return updated;
}
@@ -169,33 +233,45 @@ export function updateMembers(id: string | number, tripId: string | number, user
if (!item) return null;
const existingPaid: Record<number, number> = {};
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id) as { user_id: number; paid: number }[];
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id) as {
user_id: number;
paid: number;
}[];
for (const e of existing) existingPaid[e.user_id] = e.paid;
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
if (userIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
const insert = db.prepare(
'INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)',
);
for (const userId of userIds) insert.run(id, userId, existingPaid[userId] || 0);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(userIds.length, id);
} else {
db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
}
const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
const members = loadItemMembers(id).map((m) => ({ ...m, avatar_url: avatarUrl(m) }));
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
return { members, item: updated };
}
export function toggleMemberPaid(id: string | number, userId: string | number, paid: boolean) {
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
.run(paid ? 1 : 0, id, userId);
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?').run(
paid ? 1 : 0,
id,
userId,
);
const member = db.prepare(`
const member = db
.prepare(
`
SELECT bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ? AND bm.user_id = ?
`).get(id, userId) as BudgetItemMember | undefined;
`,
)
.get(id, userId) as BudgetItemMember | undefined;
return member ? { ...member, avatar_url: avatarUrl(member) } : null;
}
@@ -205,7 +281,9 @@ export function toggleMemberPaid(id: string | number, userId: string | number, p
// ---------------------------------------------------------------------------
export function getPerPersonSummary(tripId: string | number) {
const summary = db.prepare(`
const summary = db
.prepare(
`
SELECT bm.user_id, u.username, u.avatar,
SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned,
SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid,
@@ -215,9 +293,18 @@ export function getPerPersonSummary(tripId: string | number) {
JOIN users u ON bm.user_id = u.id
WHERE bi.trip_id = ?
GROUP BY bm.user_id
`).all(tripId) as { user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }[];
`,
)
.all(tripId) as {
user_id: number;
username: string;
avatar: string | null;
total_assigned: number;
total_paid: number;
items_count: number;
}[];
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
return summary.map((s) => ({ ...s, avatar_url: avatarUrl(s) }));
}
// ---------------------------------------------------------------------------
@@ -226,21 +313,26 @@ export function getPerPersonSummary(tripId: string | number) {
export function calculateSettlement(tripId: string | number) {
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
const allMembers = db.prepare(`
const allMembers = db
.prepare(
`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
`,
)
.all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
// Calculate net balance per user: positive = is owed money, negative = owes money
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> =
{};
for (const item of items) {
const members = allMembers.filter(m => m.budget_item_id === item.id);
const members = allMembers.filter((m) => m.budget_item_id === item.id);
if (members.length === 0) continue;
const payers = members.filter(m => m.paid);
const payers = members.filter((m) => m.paid);
if (payers.length === 0) continue; // no one marked as paid
const sharePerMember = item.total_price / members.length;
@@ -258,17 +350,22 @@ export function calculateSettlement(tripId: string | number) {
}
// Calculate optimized payment flows (greedy algorithm)
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
const people = Object.values(balances).filter((b) => Math.abs(b.balance) > 0.01);
const debtors = people.filter((p) => p.balance < -0.01).map((p) => ({ ...p, amount: -p.balance }));
const creditors = people.filter((p) => p.balance > 0.01).map((p) => ({ ...p, amount: p.balance }));
// Sort by amount descending for efficient matching
debtors.sort((a, b) => b.amount - a.amount);
creditors.sort((a, b) => b.amount - a.amount);
const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
const flows: {
from: { user_id: number; username: string; avatar_url: string | null };
to: { user_id: number; username: string; avatar_url: string | null };
amount: number;
}[] = [];
let di = 0, ci = 0;
let di = 0,
ci = 0;
while (di < debtors.length && ci < creditors.length) {
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
if (transfer > 0.01) {
@@ -285,7 +382,7 @@ export function calculateSettlement(tripId: string | number) {
}
return {
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
balances: Object.values(balances).map((b) => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
flows,
};
}
@@ -303,7 +400,7 @@ export function reorderBudgetItems(tripId: string | number, orderedIds: number[]
export function reorderBudgetCategories(tripId: string | number, orderedCategories: string[]) {
const upsert = db.prepare(
'INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?) ON CONFLICT(trip_id, category) DO UPDATE SET sort_order = excluded.sort_order'
'INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?) ON CONFLICT(trip_id, category) DO UPDATE SET sort_order = excluded.sort_order',
);
db.transaction(() => {
orderedCategories.forEach((cat, index) => upsert.run(tripId, cat, index));
+7 -5
View File
@@ -5,9 +5,9 @@ export function listCategories() {
}
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);
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);
}
@@ -16,13 +16,15 @@ export function getCategoryById(categoryId: number | string) {
}
export function updateCategory(categoryId: number | string, name?: string, color?: string, icon?: string) {
db.prepare(`
db.prepare(
`
UPDATE categories SET
name = COALESCE(?, name),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`).run(name || null, color || null, icon || null, categoryId);
`,
).run(name || null, color || null, icon || null, categoryId);
return db.prepare('SELECT * FROM categories WHERE id = ?').get(categoryId);
}
+219 -71
View File
@@ -1,9 +1,10 @@
import path from 'path';
import fs from 'fs';
import { db, canAccessTrip } from '../db/database';
import { CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
import fs from 'fs';
import path from 'path';
/* ------------------------------------------------------------------ */
/* Internal row types */
/* ------------------------------------------------------------------ */
@@ -61,12 +62,16 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
/* ------------------------------------------------------------------ */
export function loadReactions(messageId: number | string): ReactionRow[] {
return db.prepare(`
return db
.prepare(
`
SELECT r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(messageId) as ReactionRow[];
`,
)
.all(messageId) as ReactionRow[];
}
export function groupReactions(reactions: ReactionRow[]): GroupedReaction[] {
@@ -78,15 +83,26 @@ export function groupReactions(reactions: ReactionRow[]): GroupedReaction[] {
return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length }));
}
export function addOrRemoveReaction(messageId: number | string, tripId: number | string, userId: number, emoji: string): { found: boolean; reactions: GroupedReaction[] } {
export function addOrRemoveReaction(
messageId: number | string,
tripId: number | string,
userId: number,
emoji: string,
): { found: boolean; reactions: GroupedReaction[] } {
const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(messageId, tripId);
if (!msg) return { found: false, reactions: [] };
const existing = db.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(messageId, userId, emoji) as { id: number } | undefined;
const existing = db
.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?')
.get(messageId, userId, emoji) as { id: number } | undefined;
if (existing) {
db.prepare('DELETE FROM collab_message_reactions WHERE id = ?').run(existing.id);
} else {
db.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(messageId, userId, emoji);
db.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(
messageId,
userId,
emoji,
);
}
return { found: true, reactions: groupReactions(loadReactions(messageId)) };
@@ -97,45 +113,84 @@ export function addOrRemoveReaction(messageId: number | string, tripId: number |
/* ------------------------------------------------------------------ */
export function formatNote(note: CollabNote) {
const attachments = db.prepare('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?').all(note.id) as NoteFileRow[];
const attachments = db
.prepare('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?')
.all(note.id) as NoteFileRow[];
return {
...note,
avatar_url: avatarUrl(note),
attachments: attachments.map(a => ({ ...a, url: `/api/trips/${note.trip_id}/files/${a.id}/download` })),
attachments: attachments.map((a) => ({ ...a, url: `/api/trips/${note.trip_id}/files/${a.id}/download` })),
};
}
export function listNotes(tripId: string | number) {
const notes = db.prepare(`
const notes = db
.prepare(
`
SELECT n.*, u.username, u.avatar
FROM collab_notes n
JOIN users u ON n.user_id = u.id
WHERE n.trip_id = ?
ORDER BY n.pinned DESC, n.updated_at DESC
`).all(tripId) as CollabNote[];
`,
)
.all(tripId) as CollabNote[];
return notes.map(formatNote);
}
export function createNote(tripId: string | number, userId: number, data: { title: string; content?: string; category?: string; color?: string; website?: string; pinned?: boolean }) {
export function createNote(
tripId: string | number,
userId: number,
data: { title: string; content?: string; category?: string; color?: string; website?: string; pinned?: boolean },
) {
const pinned = data.pinned ? 1 : 0;
const result = db.prepare(`
const result = db
.prepare(
`
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null, pinned);
`,
)
.run(
tripId,
userId,
data.title,
data.content || null,
data.category || 'General',
data.color || '#6366f1',
data.website || null,
pinned,
);
const note = db.prepare(`
const note = db
.prepare(
`
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(result.lastInsertRowid) as CollabNote;
`,
)
.get(result.lastInsertRowid) as CollabNote;
return formatNote(note);
}
export function updateNote(tripId: string | number, noteId: string | number, data: { title?: string; content?: string; category?: string; color?: string; pinned?: number | boolean; website?: string }): ReturnType<typeof formatNote> | null {
export function updateNote(
tripId: string | number,
noteId: string | number,
data: {
title?: string;
content?: string;
category?: string;
color?: string;
pinned?: number | boolean;
website?: string;
},
): ReturnType<typeof formatNote> | null {
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
if (!existing) return null;
db.prepare(`
db.prepare(
`
UPDATE collab_notes SET
title = COALESCE(?, title),
content = CASE WHEN ? THEN ? ELSE content END,
@@ -145,19 +200,27 @@ export function updateNote(tripId: string | number, noteId: string | number, dat
website = CASE WHEN ? THEN ? ELSE website END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
`,
).run(
data.title || null,
data.content !== undefined ? 1 : 0, data.content !== undefined ? data.content : null,
data.content !== undefined ? 1 : 0,
data.content !== undefined ? data.content : null,
data.category || null,
data.color || null,
data.pinned !== undefined ? 1 : null, data.pinned ? 1 : 0,
data.website !== undefined ? 1 : 0, data.website !== undefined ? data.website : null,
noteId
data.pinned !== undefined ? 1 : null,
data.pinned ? 1 : 0,
data.website !== undefined ? 1 : 0,
data.website !== undefined ? data.website : null,
noteId,
);
const note = db.prepare(`
const note = db
.prepare(
`
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(noteId) as CollabNote;
`,
)
.get(noteId) as CollabNote;
return formatNote(note);
}
@@ -170,7 +233,11 @@ export function deleteNote(tripId: string | number, noteId: string | number): bo
const noteFiles = db.prepare('SELECT id, filename FROM trip_files WHERE note_id = ?').all(noteId) as NoteFileRow[];
for (const f of noteFiles) {
const filePath = path.join(__dirname, '../../uploads', f.filename);
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
try {
fs.unlinkSync(filePath);
} catch {
/* ignore */
}
}
db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(noteId);
@@ -182,29 +249,43 @@ export function deleteNote(tripId: string | number, noteId: string | number): bo
/* Note files */
/* ------------------------------------------------------------------ */
export function addNoteFile(tripId: string | number, noteId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }): { file: TripFile & { url: string } } | null {
export function addNoteFile(
tripId: string | number,
noteId: string | number,
file: { filename: string; originalname: string; size: number; mimetype: string },
): { file: TripFile & { url: string } } | null {
const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
if (!note) return null;
const result = db.prepare(
'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, noteId, `files/${file.filename}`, file.originalname, file.size, file.mimetype);
const result = db
.prepare(
'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)',
)
.run(tripId, noteId, `files/${file.filename}`, file.originalname, file.size, file.mimetype);
const saved = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid) as TripFile;
return { file: { ...saved, url: `/api/trips/${tripId}/files/${saved.id}/download` } };
}
export function getFormattedNoteById(noteId: string | number) {
const note = db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(noteId) as CollabNote;
const note = db
.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?')
.get(noteId) as CollabNote;
return formatNote(note);
}
export function deleteNoteFile(noteId: string | number, fileId: string | number): boolean {
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, noteId) as TripFile | undefined;
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, noteId) as
| TripFile
| undefined;
if (!file) return false;
const filePath = path.join(__dirname, '../../uploads', file.filename);
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
try {
fs.unlinkSync(filePath);
} catch {
/* ignore */
}
db.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId);
return true;
@@ -215,29 +296,43 @@ export function deleteNoteFile(noteId: string | number, fileId: string | number)
/* ------------------------------------------------------------------ */
export function getPollWithVotes(pollId: number | bigint | string) {
const poll = db.prepare(`
const poll = db
.prepare(
`
SELECT p.*, u.username, u.avatar
FROM collab_polls p
JOIN users u ON p.user_id = u.id
WHERE p.id = ?
`).get(pollId) as CollabPoll | undefined;
`,
)
.get(pollId) as CollabPoll | undefined;
if (!poll) return null;
const options: (string | { label: string })[] = JSON.parse(poll.options);
const votes = db.prepare(`
const votes = db
.prepare(
`
SELECT v.option_index, v.user_id, u.username, u.avatar
FROM collab_poll_votes v
JOIN users u ON v.user_id = u.id
WHERE v.poll_id = ?
`).all(pollId) as PollVoteRow[];
`,
)
.all(pollId) as PollVoteRow[];
const formattedOptions = options.map((label: string | { label: string }, idx: number) => ({
label: typeof label === 'string' ? label : label.label || label,
voters: votes
.filter(v => v.option_index === idx)
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
.filter((v) => v.option_index === idx)
.map((v) => ({
id: v.user_id,
user_id: v.user_id,
username: v.username,
avatar: v.avatar,
avatar_url: avatarUrl(v),
})),
}));
return {
@@ -250,26 +345,45 @@ export function getPollWithVotes(pollId: number | bigint | string) {
}
export function listPolls(tripId: string | number) {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC
`).all(tripId) as { id: number }[];
`,
)
.all(tripId) as { id: number }[];
return rows.map(row => getPollWithVotes(row.id)).filter(Boolean);
return rows.map((row) => getPollWithVotes(row.id)).filter(Boolean);
}
export function createPoll(tripId: string | number, userId: number, data: { question: string; options: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string }) {
export function createPoll(
tripId: string | number,
userId: number,
data: { question: string; options: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string },
) {
const isMultiple = data.multiple || data.multiple_choice;
const result = db.prepare(`
const result = db
.prepare(
`
INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline)
VALUES (?, ?, ?, ?, ?, ?)
`).run(tripId, userId, data.question, JSON.stringify(data.options), isMultiple ? 1 : 0, data.deadline || null);
`,
)
.run(tripId, userId, data.question, JSON.stringify(data.options), isMultiple ? 1 : 0, data.deadline || null);
return getPollWithVotes(result.lastInsertRowid);
}
export function votePoll(tripId: string | number, pollId: string | number, userId: number, optionIndex: number): { error?: string; poll?: ReturnType<typeof getPollWithVotes> } {
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId) as CollabPoll | undefined;
export function votePoll(
tripId: string | number,
pollId: string | number,
userId: number,
optionIndex: number,
): { error?: string; poll?: ReturnType<typeof getPollWithVotes> } {
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId) as
| CollabPoll
| undefined;
if (!poll) return { error: 'not_found' };
if (poll.closed) return { error: 'closed' };
@@ -278,9 +392,9 @@ export function votePoll(tripId: string | number, pollId: string | number, userI
return { error: 'invalid_index' };
}
const existingVote = db.prepare(
'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?'
).get(pollId, userId, optionIndex) as { id: number } | undefined;
const existingVote = db
.prepare('SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?')
.get(pollId, userId, optionIndex) as { id: number } | undefined;
if (existingVote) {
db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id);
@@ -288,13 +402,20 @@ export function votePoll(tripId: string | number, pollId: string | number, userI
if (!poll.multiple) {
db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(pollId, userId);
}
db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(pollId, userId, optionIndex);
db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(
pollId,
userId,
optionIndex,
);
}
return { poll: getPollWithVotes(pollId) };
}
export function closePoll(tripId: string | number, pollId: string | number): ReturnType<typeof getPollWithVotes> | null {
export function closePoll(
tripId: string | number,
pollId: string | number,
): ReturnType<typeof getPollWithVotes> | null {
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId);
if (!poll) return null;
@@ -319,7 +440,9 @@ export function formatMessage(msg: CollabMessage, reactions?: GroupedReaction[])
}
export function countMessages(tripId: string | number): number {
const row = db.prepare('SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?').get(tripId) as { cnt: number };
const row = db.prepare('SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?').get(tripId) as {
cnt: number;
};
return row.cnt;
}
@@ -337,40 +460,55 @@ export function listMessages(tripId: string | number, before?: string | number)
`;
const messages = before
? db.prepare(query).all(tripId, before) as CollabMessage[]
: db.prepare(query).all(tripId) as CollabMessage[];
? (db.prepare(query).all(tripId, before) as CollabMessage[])
: (db.prepare(query).all(tripId) as CollabMessage[]);
messages.reverse();
const msgIds = messages.map(m => m.id);
const msgIds = messages.map((m) => m.id);
const reactionsByMsg: Record<number, ReactionRow[]> = {};
if (msgIds.length > 0) {
const allReactions = db.prepare(`
const allReactions = db
.prepare(
`
SELECT r.message_id, r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id IN (${msgIds.map(() => '?').join(',')})
`).all(...msgIds) as (ReactionRow & { message_id: number })[];
`,
)
.all(...msgIds) as (ReactionRow & { message_id: number })[];
for (const r of allReactions) {
if (!reactionsByMsg[r.message_id]) reactionsByMsg[r.message_id] = [];
reactionsByMsg[r.message_id].push(r);
}
}
return messages.map(m => formatMessage(m, groupReactions(reactionsByMsg[m.id] || [])));
return messages.map((m) => formatMessage(m, groupReactions(reactionsByMsg[m.id] || [])));
}
export function createMessage(tripId: string | number, userId: number, text: string, replyTo?: number | null): { error?: string; message?: ReturnType<typeof formatMessage> } {
export function createMessage(
tripId: string | number,
userId: number,
text: string,
replyTo?: number | null,
): { error?: string; message?: ReturnType<typeof formatMessage> } {
if (replyTo) {
const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(replyTo, tripId);
if (!replyMsg) return { error: 'reply_not_found' };
}
const result = db.prepare(`
const result = db
.prepare(
`
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
`).run(tripId, userId, text.trim(), replyTo || null);
`,
)
.run(tripId, userId, text.trim(), replyTo || null);
const message = db.prepare(`
const message = db
.prepare(
`
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
@@ -378,13 +516,21 @@ export function createMessage(tripId: string | number, userId: number, text: str
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid) as CollabMessage;
`,
)
.get(result.lastInsertRowid) as CollabMessage;
return { message: formatMessage(message) };
}
export function deleteMessage(tripId: string | number, messageId: string | number, userId: number): { error?: string; username?: string } {
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(messageId, tripId) as CollabMessage | undefined;
export function deleteMessage(
tripId: string | number,
messageId: string | number,
userId: number,
): { error?: string; username?: string } {
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(messageId, tripId) as
| CollabMessage
| undefined;
if (!message) return { error: 'not_found' };
if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' };
@@ -413,7 +559,7 @@ export async function fetchLinkPreview(url: string): Promise<LinkPreviewResult>
const r = await fetch(url, {
redirect: 'error',
signal: controller.signal,
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
dispatcher: createPinnedDispatcher(ssrf.resolvedIp),
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
} as any);
clearTimeout(timeout);
@@ -421,13 +567,15 @@ export async function fetchLinkPreview(url: string): Promise<LinkPreviewResult>
const html = await r.text();
const get = (prop: string) => {
const m = html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
const m =
html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i')) ||
html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
return m ? m[1] : null;
};
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|| html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/i);
const descMeta =
html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i) ||
html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/i);
return {
title: get('title') || (titleTag ? titleTag[1].trim() : null),
+2 -1
View File
@@ -21,7 +21,8 @@ export function cookieOptions(clear = false, req?: Request) {
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
return buildOptions(clear, false);
}
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
const envSecure =
process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
const requestSecure = req?.secure === true;
return buildOptions(clear, envSecure || requestSecure);
}
+24 -13
View File
@@ -6,35 +6,46 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
}
export function listNotes(dayId: string | number, tripId: string | number) {
return db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(dayId, tripId);
return db
.prepare('SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC')
.all(dayId, tripId);
}
export function dayExists(dayId: string | number, tripId: string | number) {
return db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
}
export function createNote(dayId: string | number, tripId: string | number, text: string, time?: string, icon?: string, sort_order?: number) {
const result = db.prepare(
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run(dayId, tripId, text.trim(), time || null, icon || '\uD83D\uDCDD', sort_order ?? 9999);
export function createNote(
dayId: string | number,
tripId: string | number,
text: string,
time?: string,
icon?: string,
sort_order?: number,
) {
const result = db
.prepare('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)')
.run(dayId, tripId, text.trim(), time || null, icon || '\uD83D\uDCDD', sort_order ?? 9999);
return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid);
}
export function getNote(id: string | number, dayId: string | number, tripId: string | number) {
return db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId) as DayNote | undefined;
return db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId) as
| DayNote
| undefined;
}
export function updateNote(id: string | number, current: DayNote, fields: { text?: string; time?: string; icon?: string; sort_order?: number }) {
db.prepare(
'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ? WHERE id = ?'
).run(
export function updateNote(
id: string | number,
current: DayNote,
fields: { text?: string; time?: string; icon?: string; sort_order?: number },
) {
db.prepare('UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ? WHERE id = ?').run(
fields.text !== undefined ? fields.text.trim() : current.text,
fields.time !== undefined ? fields.time : current.time,
fields.icon !== undefined ? fields.icon : current.icon,
fields.sort_order !== undefined ? fields.sort_order : current.sort_order,
id
id,
);
return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id);
}
+130 -55
View File
@@ -1,6 +1,6 @@
import { db, canAccessTrip } from '../db/database';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers';
import { AssignmentRow, Day, DayNote } from '../types';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers';
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
@@ -11,7 +11,9 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
// ---------------------------------------------------------------------------
export function getAssignmentsForDay(dayId: number | string) {
const assignments = db.prepare(`
const assignments = db
.prepare(
`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
@@ -24,14 +26,20 @@ export function getAssignmentsForDay(dayId: number | string) {
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId) as AssignmentRow[];
`,
)
.all(dayId) as AssignmentRow[];
return assignments.map(a => {
const tags = db.prepare(`
return assignments.map((a) => {
const tags = db
.prepare(
`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(a.place_id);
`,
)
.all(a.place_id);
return {
id: a.id,
@@ -58,14 +66,16 @@ export function getAssignmentsForDay(dayId: number | string) {
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
category: a.category_id
? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
}
: null,
tags,
}
},
};
});
}
@@ -81,10 +91,12 @@ export function listDays(tripId: string | number) {
return { days: [] };
}
const dayIds = days.map(d => d.id);
const dayIds = days.map((d) => d.id);
const dayPlaceholders = dayIds.map(() => '?').join(',');
const allAssignments = db.prepare(`
const allAssignments = db
.prepare(
`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
@@ -97,30 +109,34 @@ export function listDays(tripId: string | number) {
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id IN (${dayPlaceholders})
ORDER BY da.order_index ASC, da.created_at ASC
`).all(...dayIds) as AssignmentRow[];
`,
)
.all(...dayIds) as AssignmentRow[];
const placeIds = [...new Set(allAssignments.map(a => a.place_id))];
const placeIds = [...new Set(allAssignments.map((a) => a.place_id))];
const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true });
const allAssignmentIds = allAssignments.map(a => a.id);
const allAssignmentIds = allAssignments.map((a) => a.id);
const participantsByAssignment = loadParticipantsByAssignmentIds(allAssignmentIds);
const assignmentsByDayId: Record<number, ReturnType<typeof formatAssignmentWithPlace>[]> = {};
for (const a of allAssignments) {
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []));
assignmentsByDayId[a.day_id].push(
formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []),
);
}
const allNotes = db.prepare(
`SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC`
).all(...dayIds) as DayNote[];
const allNotes = db
.prepare(`SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC`)
.all(...dayIds) as DayNote[];
const notesByDayId: Record<number, DayNote[]> = {};
for (const note of allNotes) {
if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = [];
notesByDayId[note.day_id].push(note);
}
const daysWithAssignments = days.map(day => ({
const daysWithAssignments = days.map((day) => ({
...day,
assignments: assignmentsByDayId[day.id] || [],
notes_items: notesByDayId[day.id] || [],
@@ -130,12 +146,14 @@ export function listDays(tripId: string | number) {
}
export function createDay(tripId: string | number, date?: string, notes?: string) {
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId) as { max: number | null };
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId) as {
max: number | null;
};
const dayNumber = (maxDay.max || 0) + 1;
const result = db.prepare(
'INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)'
).run(tripId, dayNumber, date || null, notes || null);
const result = db
.prepare('INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)')
.run(tripId, dayNumber, date || null, notes || null);
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as Day;
return { ...day, assignments: [] };
@@ -149,7 +167,7 @@ export function updateDay(id: string | number, current: Day, fields: { notes?: s
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(
fields.notes || null,
'title' in fields ? (fields.title ?? null) : current.title,
id
id,
);
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day;
return { ...updatedDay, assignments: getAssignmentsForDay(id) };
@@ -177,12 +195,16 @@ export interface DayAccommodation {
}
function getAccommodationWithPlace(id: number | bigint) {
return db.prepare(`
return db
.prepare(
`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
LEFT JOIN places p ON a.place_id = p.id
WHERE a.id = ?
`).get(id);
`,
)
.get(id);
}
// ---------------------------------------------------------------------------
@@ -190,7 +212,9 @@ function getAccommodationWithPlace(id: number | bigint) {
// ---------------------------------------------------------------------------
export function listAccommodations(tripId: string | number) {
return db.prepare(`
return db
.prepare(
`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng,
r.title as reservation_title
FROM day_accommodations a
@@ -198,10 +222,17 @@ export function listAccommodations(tripId: string | number) {
LEFT JOIN reservations r ON r.accommodation_id = a.id
WHERE a.trip_id = ?
ORDER BY a.created_at ASC
`).all(tripId);
`,
)
.all(tripId);
}
export function validateAccommodationRefs(tripId: string | number, placeId?: number, startDayId?: number, endDayId?: number) {
export function validateAccommodationRefs(
tripId: string | number,
placeId?: number,
startDayId?: number,
endDayId?: number,
) {
const errors: { field: string; message: string }[] = [];
if (placeId !== undefined) {
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
@@ -232,39 +263,73 @@ interface CreateAccommodationData {
export function createAccommodation(tripId: string | number, data: CreateAccommodationData) {
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data;
const result = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null);
const result = db
.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
)
.run(
tripId,
place_id,
start_day_id,
end_day_id,
check_in || null,
check_in_end || null,
check_out || null,
confirmation || null,
notes || null,
);
const accommodationId = result.lastInsertRowid;
// Auto-create linked reservation for this accommodation
const placeName = (db.prepare('SELECT name FROM places WHERE id = ?').get(place_id) as { name: string } | undefined)?.name || 'Hotel';
const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null;
const placeName =
(db.prepare('SELECT name FROM places WHERE id = ?').get(place_id) as { name: string } | undefined)?.name || 'Hotel';
const startDayDate =
(db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null;
const meta: Record<string, string> = {};
if (check_in) meta.check_in_time = check_in;
if (check_in_end) meta.check_in_end_time = check_in_end;
if (check_out) meta.check_out_time = check_out;
db.prepare(`
db.prepare(
`
INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?)
`).run(
tripId, start_day_id, placeName, startDayDate || null, null,
confirmation || null, notes || null, accommodationId,
Object.keys(meta).length > 0 ? JSON.stringify(meta) : null
`,
).run(
tripId,
start_day_id,
placeName,
startDayDate || null,
null,
confirmation || null,
notes || null,
accommodationId,
Object.keys(meta).length > 0 ? JSON.stringify(meta) : null,
);
return getAccommodationWithPlace(accommodationId);
}
export function getAccommodation(id: string | number, tripId: string | number) {
return db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId) as DayAccommodation | undefined;
return db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId) as
| DayAccommodation
| undefined;
}
export function updateAccommodation(id: string | number, existing: DayAccommodation, fields: {
place_id?: number; start_day_id?: number; end_day_id?: number;
check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string;
}) {
export function updateAccommodation(
id: string | number,
existing: DayAccommodation,
fields: {
place_id?: number;
start_day_id?: number;
end_day_id?: number;
check_in?: string;
check_in_end?: string;
check_out?: string;
confirmation?: string;
notes?: string;
},
) {
const newPlaceId = fields.place_id !== undefined ? fields.place_id : existing.place_id;
const newStartDayId = fields.start_day_id !== undefined ? fields.start_day_id : existing.start_day_id;
const newEndDayId = fields.end_day_id !== undefined ? fields.end_day_id : existing.end_day_id;
@@ -275,29 +340,39 @@ export function updateAccommodation(id: string | number, existing: DayAccommodat
const newNotes = fields.notes !== undefined ? fields.notes : existing.notes;
db.prepare(
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?',
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id);
// Sync check-in/out/confirmation to linked reservation
const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined;
const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as
| { id: number; metadata: string | null }
| undefined;
if (linkedRes) {
const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {};
if (newCheckIn) meta.check_in_time = newCheckIn;
if (newCheckInEnd) meta.check_in_end_time = newCheckInEnd;
if (newCheckOut) meta.check_out_time = newCheckOut;
db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?')
.run(JSON.stringify(meta), newConfirmation || null, linkedRes.id);
db.prepare(
'UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?',
).run(JSON.stringify(meta), newConfirmation || null, linkedRes.id);
}
return getAccommodationWithPlace(Number(id));
}
/** Delete accommodation and its linked reservation (and any linked budget item). */
export function deleteAccommodation(id: string | number): { linkedReservationId: number | null; deletedBudgetItemId: number | null } {
const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined;
export function deleteAccommodation(id: string | number): {
linkedReservationId: number | null;
deletedBudgetItemId: number | null;
} {
const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as
| { id: number }
| undefined;
let deletedBudgetItemId: number | null = null;
if (linkedRes) {
const linkedBudget = db.prepare('SELECT id FROM budget_items WHERE reservation_id = ?').get(linkedRes.id) as { id: number } | undefined;
const linkedBudget = db.prepare('SELECT id FROM budget_items WHERE reservation_id = ?').get(linkedRes.id) as
| { id: number }
| undefined;
if (linkedBudget) {
db.prepare('DELETE FROM budget_items WHERE id = ?').run(linkedBudget.id);
deletedBudgetItemId = linkedBudget.id;

Some files were not shown because too many files have changed in this diff Show More