mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
chore: apply prettier on the entire project
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
+706
-256
File diff suppressed because it is too large
Load Diff
+199
-22
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.'}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+836
-209
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
+678
-264
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user