mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules (auth, trips, days, places, assignments, packing, todo, budget, reservations, collab, files, photos, journey, share, settings, backup, oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories, tags, notifications, system-notices) served through a per-prefix dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT httpOnly cookie auth, with behavioural parity for every route. Client: React 19 upgrade, "page = wiring container + data hook" pattern across all pages, per-domain Zustand stores bound to @trek/shared contracts, and decomposition of the large components (DayPlanSidebar, PackingListPanel, CollabNotes, FileManager, MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal, BudgetPanel, PlaceFormModal, ...) into focused render units backed by in-file hooks. Apply the shared global request pipeline (helmet/CSP, CORS, HSTS, forced HTTPS, the global MFA policy and request logging) to the NestJS instance as well, so a migrated route is protected identically to the legacy fallback rather than bypassing it.
This commit is contained in:
+2
-142
@@ -1,14 +1,10 @@
|
||||
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 { applyGlobalMiddleware } from './middleware/globalMiddleware';
|
||||
import { db } from './db/database';
|
||||
|
||||
import authRoutes from './routes/auth';
|
||||
@@ -60,143 +56,7 @@ import { getMcpSafeUrl } from './services/notifications';
|
||||
export function createApp(): express.Application {
|
||||
const app = express();
|
||||
|
||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||
if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) {
|
||||
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||
}
|
||||
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
let corsOrigin: cors.CorsOptions['origin'];
|
||||
if (allowedOrigins) {
|
||||
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||
else callback(new Error('Not allowed by CORS'));
|
||||
};
|
||||
} else if (process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
corsOrigin = false;
|
||||
} else {
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||
// HSTS is worth enabling any time we're serving production traffic,
|
||||
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
|
||||
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
|
||||
// proxy handles the redirect for them), and the previous "HSTS off by
|
||||
// default" meant those instances never advertised HSTS at all.
|
||||
//
|
||||
// `includeSubDomains` stays OFF by default on purpose: an instance
|
||||
// running on an apex domain would otherwise force HTTPS on every
|
||||
// sibling subdomain the same operator may still be running over plain
|
||||
// HTTP. Operators who want the stricter policy opt in with
|
||||
// `HSTS_INCLUDE_SUBDOMAINS=true`.
|
||||
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
|
||||
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
|
||||
|
||||
// RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable.
|
||||
// /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it
|
||||
// with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for
|
||||
// browser-based DCR/authorization preflights — the global cors({ origin: false }) would
|
||||
// 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(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://routing.openstreetmap.de/",
|
||||
"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) => {
|
||||
if (req.path === '/api/health') return next();
|
||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||
});
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: '100kb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
app.use(enforceGlobalMfaPolicy);
|
||||
|
||||
// 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 redact = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return (value as unknown[]).map(redact);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path === '/api/health') return next();
|
||||
const startedAt = Date.now();
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (res.statusCode >= 500) {
|
||||
logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
} else if (res.statusCode >= 400) {
|
||||
logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
}
|
||||
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(redact(req.query))}` : '';
|
||||
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(redact(req.body))}` : '';
|
||||
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
applyGlobalMiddleware(app);
|
||||
|
||||
// Static: avatars, covers, and journey photos.
|
||||
//
|
||||
|
||||
@@ -13,6 +13,13 @@ const isTest = process.env.NODE_ENV === 'test';
|
||||
let dbPath: string;
|
||||
if (isTest) {
|
||||
dbPath = ':memory:';
|
||||
} else if (process.env.TREK_DB_FILE) {
|
||||
// Explicit DB file (used by the Playwright E2E harness to run against an
|
||||
// isolated, throwaway database instead of the real data/travel.db). Purely
|
||||
// additive — when unset the default path below is used exactly as before.
|
||||
dbPath = process.env.TREK_DB_FILE;
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
} else {
|
||||
const dataDir = path.join(__dirname, '../../data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
|
||||
+8
-4
@@ -4,13 +4,13 @@ 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 { applyGlobalMiddleware } from './middleware/globalMiddleware';
|
||||
|
||||
// Create upload and data directories on startup
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
@@ -115,11 +115,15 @@ async function bootstrap(): Promise<void> {
|
||||
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
|
||||
// is reached separately and never passes through Nest's parser.
|
||||
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
|
||||
// cookie-parser so the auth guard can read the existing `trek_session` cookie.
|
||||
nestApp.use(cookieParser());
|
||||
const nestInstance = nestApp.getHttpAdapter().getInstance();
|
||||
// Apply the SAME global request pipeline the legacy app uses (helmet/CSP, CORS,
|
||||
// HSTS, forced-HTTPS, the global MFA policy, request logging + cookie-parser) so a
|
||||
// migrated Nest route is protected identically to the legacy fallback. Without this
|
||||
// the dispatcher forwards Nest paths straight to this instance, bypassing all of it.
|
||||
// Nest does its own body parsing, so bodyParser:false avoids parsing twice.
|
||||
applyGlobalMiddleware(nestInstance, { bodyParser: false });
|
||||
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
|
||||
await nestApp.init();
|
||||
const nestInstance = nestApp.getHttpAdapter().getInstance();
|
||||
|
||||
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
|
||||
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
|
||||
|
||||
@@ -46,17 +46,16 @@ export function registerMcpPrompts(server: McpServer, _userId: number, isStaticT
|
||||
if (!summary) {
|
||||
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 budgetTotal = budget?.reduce((sum: number, b: any) => sum + (b.total_price || 0), 0) || 0;
|
||||
const { trip, days, members, budget, packing, reservations, collab_notes } = summary;
|
||||
const memberList = [members?.owner, ...(members?.collaborators || [])].filter(Boolean);
|
||||
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
|
||||
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
|
||||
Members: ${members?.length || 0} (${members?.map((m: any) => m.name || m.email).join(', ') || 'none'})
|
||||
Members: ${memberList.length} (${memberList.map((m: any) => m.name || m.email).join(', ') || 'none'})
|
||||
Days: ${days?.length || 0}
|
||||
Packing: ${packingStats.packed}/${packingStats.total} items packed
|
||||
Budget: ${budgetTotal} ${trip?.currency || 'EUR'} total
|
||||
Packing: ${packing?.checked || 0}/${packing?.total || 0} items packed
|
||||
Budget: ${budget?.total || 0} ${trip?.currency || 'EUR'} total
|
||||
Reservations: ${reservations?.length || 0}
|
||||
Collab Notes: ${collabNotes?.length || 0}
|
||||
Collab Notes: ${collab_notes?.length || 0}
|
||||
${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`;
|
||||
return {
|
||||
description: `Summary of trip "${trip?.title || tripId}"`,
|
||||
@@ -118,7 +117,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
|
||||
}
|
||||
const { trip, budget } = summary;
|
||||
const currency = trip?.currency || 'EUR';
|
||||
const byCategory = (budget || []).reduce((acc: Record<string, number>, item: any) => {
|
||||
const byCategory = (budget?.items || []).reduce((acc: Record<string, number>, item: any) => {
|
||||
const cat = item.category || 'Uncategorized';
|
||||
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
|
||||
return acc;
|
||||
@@ -128,7 +127,8 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
|
||||
.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);
|
||||
const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length);
|
||||
const perPerson = (total / memberCount).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.'}` } }],
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { logDebug, logWarn, logError } from '../services/auditLog';
|
||||
import { enforceGlobalMfaPolicy } from './mfaPolicy';
|
||||
|
||||
/**
|
||||
* The global request pipeline shared by the legacy Express app and the NestJS
|
||||
* instance. Both mount the *exact same* config so a request hitting a migrated
|
||||
* Nest route is protected identically to one hitting the legacy fallback
|
||||
* (helmet/CSP, CORS, HSTS, forced-HTTPS, the global MFA policy and request
|
||||
* logging). Keeping it in one place is what makes the strangler dispatch
|
||||
* behaviourally transparent — and is the prerequisite for retiring Express,
|
||||
* since the Nest instance must carry the whole shell on its own.
|
||||
*
|
||||
* `bodyParser` is opt-out: the Nest instance does its own body parsing, so it
|
||||
* passes `false` to avoid parsing the request twice.
|
||||
*/
|
||||
export function applyGlobalMiddleware(
|
||||
app: express.Application,
|
||||
opts: { bodyParser?: boolean } = {},
|
||||
): void {
|
||||
const { bodyParser = true } = opts;
|
||||
|
||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||
if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) {
|
||||
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||
}
|
||||
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
let corsOrigin: cors.CorsOptions['origin'];
|
||||
if (allowedOrigins) {
|
||||
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||
else callback(new Error('Not allowed by CORS'));
|
||||
};
|
||||
} else if (process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
corsOrigin = false;
|
||||
} else {
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||
// HSTS is worth enabling any time we're serving production traffic,
|
||||
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
|
||||
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
|
||||
// proxy handles the redirect for them), and the previous "HSTS off by
|
||||
// default" meant those instances never advertised HSTS at all.
|
||||
//
|
||||
// `includeSubDomains` stays OFF by default on purpose: an instance
|
||||
// running on an apex domain would otherwise force HTTPS on every
|
||||
// sibling subdomain the same operator may still be running over plain
|
||||
// HTTP. Operators who want the stricter policy opt in with
|
||||
// `HSTS_INCLUDE_SUBDOMAINS=true`.
|
||||
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
|
||||
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
|
||||
|
||||
// RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable.
|
||||
// /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it
|
||||
// with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for
|
||||
// browser-based DCR/authorization preflights — the global cors({ origin: false }) would
|
||||
// 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(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://routing.openstreetmap.de/",
|
||||
"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) => {
|
||||
if (req.path === '/api/health') return next();
|
||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||
});
|
||||
}
|
||||
|
||||
if (bodyParser) {
|
||||
app.use(express.json({ limit: '100kb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
}
|
||||
app.use(cookieParser());
|
||||
app.use(enforceGlobalMfaPolicy);
|
||||
|
||||
// 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 redact = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return (value as unknown[]).map(redact);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path === '/api/health') return next();
|
||||
const startedAt = Date.now();
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - startedAt;
|
||||
if (res.statusCode >= 500) {
|
||||
logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
} else if (res.statusCode >= 400) {
|
||||
logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
||||
}
|
||||
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(redact(req.query))}` : '';
|
||||
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(redact(req.body))}` : '';
|
||||
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,33 @@ server/src/nest/<domain>/<domain>.module.ts # registered in app.module.t
|
||||
|
||||
Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
|
||||
(operators can override at runtime via the `NEST_PREFIXES` env var — instant
|
||||
rollback, no redeploy).
|
||||
rollback, no redeploy). Trip-scoped mounts use a pattern prefix with a `:param`
|
||||
segment (e.g. `/api/trips/:tripId/packing`); the matcher routes only that nested
|
||||
mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
|
||||
|
||||
## Migrated so far
|
||||
|
||||
- **Phase 1 (leaf):** weather, airports, config (public), system-notices, maps,
|
||||
categories, tags, notifications, atlas.
|
||||
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
|
||||
|
||||
## Cross-cutting Foundation pieces
|
||||
|
||||
- `common/idempotency.interceptor.ts` — global `APP_INTERCEPTOR` replaying the
|
||||
client's `X-Idempotency-Key` on mutations, mirroring the legacy
|
||||
`applyIdempotency` middleware so retried writes don't double-apply.
|
||||
- `strangler.ts` — supports both static prefixes and `:param` pattern prefixes.
|
||||
|
||||
## Parity gotchas worth remembering
|
||||
|
||||
- A POST that answers with `res.json` in Express stays **200**; add `@HttpCode(200)`
|
||||
(Nest defaults POST to 201). Creates that Express sends as 201 need nothing.
|
||||
- Static sub-routes that collide with a `:id` param (e.g. `/in-app/all` vs
|
||||
`/in-app/:id`, `/reorder` vs `/:id`) must be declared **before** the param route.
|
||||
- Reproduce bespoke admin/error wording exactly — e.g. notifications' `test-smtp`
|
||||
returns `{ error: 'Admin only' }`, not the AdminGuard's `Admin access required`.
|
||||
- Trip-scoped routes verify trip access (404) and the relevant permission (403)
|
||||
per handler and forward `X-Socket-Id` to the WebSocket broadcast.
|
||||
|
||||
## Parity is law
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, NotFoundException, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AdminService } from './admin.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { writeAudit, getClientIp, logInfo } from '../../services/auditLog';
|
||||
import { send as sendNotification } from '../../services/notificationService';
|
||||
import type { User } from '../../types';
|
||||
|
||||
/** Throw the legacy {error,status} envelope when a service call reports failure. */
|
||||
function ok<T>(result: T): Exclude<T, { error: string }> {
|
||||
if (result && typeof result === 'object' && 'error' in (result as Record<string, unknown>)) {
|
||||
const r = result as unknown as { error: string; status?: number };
|
||||
throw new HttpException({ error: r.error }, r.status ?? 400);
|
||||
}
|
||||
return result as Exclude<T, { error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/admin — admin-only control surface (users, stats, permissions, audit log,
|
||||
* OIDC settings, invites, feature toggles, packing templates, addons, MCP/OAuth
|
||||
* sessions, JWT rotation, default user settings).
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/admin.ts):
|
||||
* admin-gated, the {error,status} envelopes, the audit-log writes, the MCP
|
||||
* session invalidation on addon/collab changes, create-201 vs the rest 200, and
|
||||
* the dev-only test-notification endpoint (404 outside development).
|
||||
*/
|
||||
@Controller('api/admin')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
export class AdminController {
|
||||
constructor(private readonly admin: AdminService) {}
|
||||
|
||||
// ── Users ──
|
||||
@Get('users')
|
||||
listUsers() { return { users: this.admin.listUsers() }; }
|
||||
|
||||
@Post('users')
|
||||
@HttpCode(201)
|
||||
createUser(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = ok(this.admin.createUser(body));
|
||||
writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Put('users/:id')
|
||||
updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = ok(this.admin.updateUser(id, body));
|
||||
writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
|
||||
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(this.admin.deleteUser(id, user.id));
|
||||
writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
||||
logInfo(`Admin ${user.email} deleted user ${result.email}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Stats / permissions / audit ──
|
||||
@Get('stats')
|
||||
stats() { return this.admin.getStats(); }
|
||||
|
||||
@Get('permissions')
|
||||
permissions() { return this.admin.getPermissions(); }
|
||||
|
||||
@Put('permissions')
|
||||
savePermissions(@CurrentUser() user: User, @Body() body: { permissions?: unknown }, @Req() req: Request) {
|
||||
if (!body.permissions || typeof body.permissions !== 'object') {
|
||||
throw new HttpException({ error: 'permissions object required' }, 400);
|
||||
}
|
||||
const result = this.admin.savePermissions(body.permissions as unknown as Parameters<AdminService['savePermissions']>[0]);
|
||||
writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
|
||||
return { success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) };
|
||||
}
|
||||
|
||||
@Get('audit-log')
|
||||
auditLog(@Query() query: { limit?: string; offset?: string }) { return this.admin.getAuditLog(query); }
|
||||
|
||||
// ── OIDC ──
|
||||
@Get('oidc')
|
||||
getOidc() { return this.admin.getOidcSettings(); }
|
||||
|
||||
@Put('oidc')
|
||||
updateOidc(@CurrentUser() user: User, @Body() body: { issuer?: string } & Record<string, unknown>, @Req() req: Request) {
|
||||
const result = this.admin.updateOidcSettings(body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), details: { issuer_set: !!body.issuer } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('save-demo-baseline')
|
||||
@HttpCode(200)
|
||||
saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
|
||||
const result = this.admin.saveDemoBaseline();
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||
return { success: true, message: result.message };
|
||||
}
|
||||
|
||||
// ── GitHub / version ──
|
||||
@Get('github-releases')
|
||||
async githubReleases(@Query('per_page') perPage = '10', @Query('page') page = '1') {
|
||||
return this.admin.getGithubReleases(String(perPage), String(page));
|
||||
}
|
||||
|
||||
@Get('version-check')
|
||||
async versionCheck() { return this.admin.checkVersion(); }
|
||||
|
||||
// ── Admin notification preferences ──
|
||||
@Get('notification-preferences')
|
||||
getNotificationPrefs(@CurrentUser() user: User) { return this.admin.getPreferencesMatrix(user.id, user.role); }
|
||||
|
||||
@Put('notification-preferences')
|
||||
setNotificationPrefs(@CurrentUser() user: User, @Body() body: unknown) {
|
||||
this.admin.setAdminPreferences(user.id, body);
|
||||
return this.admin.getPreferencesMatrix(user.id, user.role);
|
||||
}
|
||||
|
||||
// ── Invites ──
|
||||
@Get('invites')
|
||||
listInvites() { return { invites: this.admin.listInvites() }; }
|
||||
|
||||
@Post('invites')
|
||||
@HttpCode(201)
|
||||
createInvite(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = this.admin.createInvite(user.id, body);
|
||||
writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays } });
|
||||
return { invite: result.invite };
|
||||
}
|
||||
|
||||
@Delete('invites/:id')
|
||||
deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
ok(this.admin.deleteInvite(id));
|
||||
writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Feature toggles ──
|
||||
@Get('bag-tracking')
|
||||
getBagTracking() { return this.admin.getBagTracking(); }
|
||||
|
||||
@Put('bag-tracking')
|
||||
updateBagTracking(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
|
||||
const result = this.admin.updateBagTracking(body.enabled);
|
||||
writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('places-photos')
|
||||
getPlacesPhotos() { return this.admin.getPlacesPhotos(); }
|
||||
|
||||
@Put('places-photos')
|
||||
updatePlacesPhotos(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
|
||||
if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400);
|
||||
const result = this.admin.updatePlacesPhotos(body.enabled);
|
||||
writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('places-autocomplete')
|
||||
getPlacesAutocomplete() { return this.admin.getPlacesAutocomplete(); }
|
||||
|
||||
@Put('places-autocomplete')
|
||||
updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
|
||||
if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400);
|
||||
const result = this.admin.updatePlacesAutocomplete(body.enabled);
|
||||
writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('places-details')
|
||||
getPlacesDetails() { return this.admin.getPlacesDetails(); }
|
||||
|
||||
@Put('places-details')
|
||||
updatePlacesDetails(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
|
||||
if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400);
|
||||
const result = this.admin.updatePlacesDetails(body.enabled);
|
||||
writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('collab-features')
|
||||
getCollabFeatures() { return this.admin.getCollabFeatures(); }
|
||||
|
||||
@Put('collab-features')
|
||||
updateCollabFeatures(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = this.admin.updateCollabFeatures(body);
|
||||
this.admin.invalidateMcpSessions();
|
||||
writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Packing templates ──
|
||||
@Get('packing-templates')
|
||||
listPackingTemplates() { return { templates: this.admin.listPackingTemplates() }; }
|
||||
|
||||
@Get('packing-templates/:id')
|
||||
getPackingTemplate(@Param('id') id: string) { return ok(this.admin.getPackingTemplate(id)); }
|
||||
|
||||
@Post('packing-templates')
|
||||
@HttpCode(201)
|
||||
createPackingTemplate(@CurrentUser() user: User, @Body() body: { name?: unknown }) {
|
||||
return ok(this.admin.createPackingTemplate(body.name, user.id));
|
||||
}
|
||||
|
||||
@Put('packing-templates/:id')
|
||||
updatePackingTemplate(@Param('id') id: string, @Body() body: unknown) { return ok(this.admin.updatePackingTemplate(id, body)); }
|
||||
|
||||
@Delete('packing-templates/:id')
|
||||
deletePackingTemplate(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(this.admin.deletePackingTemplate(id));
|
||||
writeAudit({ userId: user.id, action: 'admin.packing_template_delete', resource: String(id), ip: getClientIp(req), details: { name: result.name } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('packing-templates/:id/categories')
|
||||
@HttpCode(201)
|
||||
createTemplateCategory(@Param('id') id: string, @Body() body: { name?: unknown }) {
|
||||
return ok(this.admin.createTemplateCategory(id, body.name));
|
||||
}
|
||||
|
||||
@Put('packing-templates/:templateId/categories/:catId')
|
||||
updateTemplateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: unknown) {
|
||||
return ok(this.admin.updateTemplateCategory(templateId, catId, body));
|
||||
}
|
||||
|
||||
@Delete('packing-templates/:templateId/categories/:catId')
|
||||
deleteTemplateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string) {
|
||||
ok(this.admin.deleteTemplateCategory(templateId, catId));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('packing-templates/:templateId/categories/:catId/items')
|
||||
@HttpCode(201)
|
||||
createTemplateItem(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: { name?: unknown }) {
|
||||
return ok(this.admin.createTemplateItem(templateId, catId, body.name));
|
||||
}
|
||||
|
||||
@Put('packing-templates/:templateId/items/:itemId')
|
||||
updateTemplateItem(@Param('itemId') itemId: string, @Body() body: unknown) { return ok(this.admin.updateTemplateItem(itemId, body)); }
|
||||
|
||||
@Delete('packing-templates/:templateId/items/:itemId')
|
||||
deleteTemplateItem(@Param('itemId') itemId: string) {
|
||||
ok(this.admin.deleteTemplateItem(itemId));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Addons ──
|
||||
@Get('addons')
|
||||
listAddons() { return { addons: this.admin.listAddons() }; }
|
||||
|
||||
@Put('addons/:id')
|
||||
updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = ok(this.admin.updateAddon(id, body));
|
||||
writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
|
||||
this.admin.invalidateMcpSessions();
|
||||
return { addon: result.addon };
|
||||
}
|
||||
|
||||
// ── MCP tokens / OAuth sessions ──
|
||||
@Get('mcp-tokens')
|
||||
listMcpTokens() { return { tokens: this.admin.listMcpTokens() }; }
|
||||
|
||||
@Delete('mcp-tokens/:id')
|
||||
deleteMcpToken(@Param('id') id: string) {
|
||||
ok(this.admin.deleteMcpToken(id));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('oauth-sessions')
|
||||
listOAuthSessions() { return { sessions: this.admin.listOAuthSessions() }; }
|
||||
|
||||
@Delete('oauth-sessions/:id')
|
||||
revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
ok(this.admin.revokeOAuthSession(id));
|
||||
writeAudit({ userId: user.id, action: 'admin.oauth_session.revoke', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── JWT rotation ──
|
||||
@Post('rotate-jwt-secret')
|
||||
@HttpCode(200)
|
||||
rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
|
||||
const result = this.admin.rotateJwtSecret();
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Default user settings ──
|
||||
@Get('default-user-settings')
|
||||
getDefaultUserSettings() { return this.admin.getAdminUserDefaults(); }
|
||||
|
||||
@Put('default-user-settings')
|
||||
setDefaultUserSettings(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw new HttpException({ error: 'Object body required' }, 400);
|
||||
}
|
||||
try {
|
||||
this.admin.setAdminUserDefaults(body as unknown as Record<string, unknown>);
|
||||
writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record<string, unknown> });
|
||||
return this.admin.getAdminUserDefaults();
|
||||
} catch (err) {
|
||||
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dev-only: test notification (404 outside development, mirroring the conditional mount) ──
|
||||
@Post('dev/test-notification')
|
||||
@HttpCode(200)
|
||||
async devTestNotification(@CurrentUser() user: User, @Body() body: { event?: string; scope?: string; targetId?: number; params?: Record<string, unknown>; inApp?: boolean }) {
|
||||
if (process.env.NODE_ENV?.toLowerCase() !== 'development') {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
try {
|
||||
await sendNotification({
|
||||
event: body.event ?? 'trip_reminder',
|
||||
actorId: user.id,
|
||||
scope: body.scope ?? 'user',
|
||||
targetId: body.targetId ?? user.id,
|
||||
params: { actor: user.email, ...(body.params ?? {}) },
|
||||
inApp: body.inApp,
|
||||
} as unknown as Parameters<typeof sendNotification>[0]);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as svc from '../../services/adminService';
|
||||
import { getAdminUserDefaults, setAdminUserDefaults } from '../../services/settingsService';
|
||||
import { invalidateMcpSessions } from '../../mcp';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing admin service (+ the settings,
|
||||
* MCP-session and notification-preference helpers the legacy route used). All
|
||||
* business logic, audit-relevant return shapes and the addon/MCP invalidation
|
||||
* reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
// Users
|
||||
listUsers() { return svc.listUsers(); }
|
||||
createUser(body: unknown) { return svc.createUser(body as Parameters<typeof svc.createUser>[0]); }
|
||||
updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters<typeof svc.updateUser>[1]); }
|
||||
deleteUser(id: string, actingUserId: number) { return svc.deleteUser(id, actingUserId); }
|
||||
|
||||
getStats() { return svc.getStats(); }
|
||||
getPermissions() { return svc.getPermissions(); }
|
||||
savePermissions(permissions: Parameters<typeof svc.savePermissions>[0]) { return svc.savePermissions(permissions); }
|
||||
getAuditLog(query: { limit?: string; offset?: string }) { return svc.getAuditLog(query); }
|
||||
|
||||
getOidcSettings() { return svc.getOidcSettings(); }
|
||||
updateOidcSettings(body: unknown) { return svc.updateOidcSettings(body as Parameters<typeof svc.updateOidcSettings>[0]); }
|
||||
saveDemoBaseline() { return svc.saveDemoBaseline(); }
|
||||
|
||||
getGithubReleases(perPage: string, page: string) { return svc.getGithubReleases(perPage, page); }
|
||||
checkVersion() { return svc.checkVersion(); }
|
||||
|
||||
// Invites
|
||||
listInvites() { return svc.listInvites(); }
|
||||
createInvite(userId: number, body: unknown) { return svc.createInvite(userId, body as Parameters<typeof svc.createInvite>[1]); }
|
||||
deleteInvite(id: string) { return svc.deleteInvite(id); }
|
||||
|
||||
// Feature toggles
|
||||
getBagTracking() { return svc.getBagTracking(); }
|
||||
updateBagTracking(enabled: unknown) { return svc.updateBagTracking(enabled as boolean); }
|
||||
getPlacesPhotos() { return svc.getPlacesPhotos(); }
|
||||
updatePlacesPhotos(enabled: boolean) { return svc.updatePlacesPhotos(enabled); }
|
||||
getPlacesAutocomplete() { return svc.getPlacesAutocomplete(); }
|
||||
updatePlacesAutocomplete(enabled: boolean) { return svc.updatePlacesAutocomplete(enabled); }
|
||||
getPlacesDetails() { return svc.getPlacesDetails(); }
|
||||
updatePlacesDetails(enabled: boolean) { return svc.updatePlacesDetails(enabled); }
|
||||
getCollabFeatures() { return svc.getCollabFeatures(); }
|
||||
updateCollabFeatures(body: unknown) { return svc.updateCollabFeatures(body as Parameters<typeof svc.updateCollabFeatures>[0]); }
|
||||
|
||||
// Packing templates
|
||||
listPackingTemplates() { return svc.listPackingTemplates(); }
|
||||
getPackingTemplate(id: string) { return svc.getPackingTemplate(id); }
|
||||
createPackingTemplate(name: unknown, userId: number) { return svc.createPackingTemplate(name as string, userId); }
|
||||
updatePackingTemplate(id: string, body: unknown) { return svc.updatePackingTemplate(id, body as Parameters<typeof svc.updatePackingTemplate>[1]); }
|
||||
deletePackingTemplate(id: string) { return svc.deletePackingTemplate(id); }
|
||||
createTemplateCategory(templateId: string, name: unknown) { return svc.createTemplateCategory(templateId, name as string); }
|
||||
updateTemplateCategory(templateId: string, catId: string, body: unknown) { return svc.updateTemplateCategory(templateId, catId, body as Parameters<typeof svc.updateTemplateCategory>[2]); }
|
||||
deleteTemplateCategory(templateId: string, catId: string) { return svc.deleteTemplateCategory(templateId, catId); }
|
||||
createTemplateItem(templateId: string, catId: string, name: unknown) { return svc.createTemplateItem(templateId, catId, name as string); }
|
||||
updateTemplateItem(itemId: string, body: unknown) { return svc.updateTemplateItem(itemId, body as Parameters<typeof svc.updateTemplateItem>[1]); }
|
||||
deleteTemplateItem(itemId: string) { return svc.deleteTemplateItem(itemId); }
|
||||
|
||||
// Addons + tokens + sessions
|
||||
listAddons() { return svc.listAddons(); }
|
||||
updateAddon(id: string, body: unknown) { return svc.updateAddon(id, body as Parameters<typeof svc.updateAddon>[1]); }
|
||||
listMcpTokens() { return svc.listMcpTokens(); }
|
||||
deleteMcpToken(id: string) { return svc.deleteMcpToken(id); }
|
||||
listOAuthSessions() { return svc.listOAuthSessions(); }
|
||||
revokeOAuthSession(id: string) { return svc.revokeOAuthSession(id); }
|
||||
rotateJwtSecret() { return svc.rotateJwtSecret(); }
|
||||
|
||||
invalidateMcpSessions() { invalidateMcpSessions(); }
|
||||
|
||||
// Settings + notification preference helpers (non-admin-service modules)
|
||||
getAdminUserDefaults() { return getAdminUserDefaults(); }
|
||||
setAdminUserDefaults(body: Record<string, unknown>) { return setAdminUserDefaults(body); }
|
||||
getPreferencesMatrix(userId: number, role: string) { return getPreferencesMatrix(userId, role, 'admin'); }
|
||||
setAdminPreferences(userId: number, body: unknown) { return setAdminPreferences(userId, body as Parameters<typeof setAdminPreferences>[1]); }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Get, HttpException, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import type { Airport } from '@trek/shared';
|
||||
import { AirportsService } from './airports.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
/**
|
||||
* /api/airports — typeahead search + single lookup by IATA code.
|
||||
*
|
||||
* Behaviour is byte-identical to the legacy Express route (server/src/routes/
|
||||
* airports.ts): both endpoints require auth, an absent/non-string query answers
|
||||
* with `[]` (not a 400), and an unknown IATA code 404s with the exact
|
||||
* `{ error: 'Airport not found' }` body.
|
||||
*
|
||||
* The `search` route is declared before `:iata` so the static segment wins over
|
||||
* the param, matching the legacy router's registration order.
|
||||
*/
|
||||
@Controller('api/airports')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AirportsController {
|
||||
constructor(private readonly airports: AirportsService) {}
|
||||
|
||||
@Get('search')
|
||||
search(@Query('q') q?: string | string[]): Airport[] {
|
||||
// Express coerces a missing/array query to '' and returns [] for it.
|
||||
const term = typeof q === 'string' ? q : '';
|
||||
if (!term) return [];
|
||||
return this.airports.search(term);
|
||||
}
|
||||
|
||||
@Get(':iata')
|
||||
findByIata(@Param('iata') iata: string): Airport {
|
||||
const airport = this.airports.findByIata(iata);
|
||||
if (!airport) {
|
||||
throw new HttpException({ error: 'Airport not found' }, 404);
|
||||
}
|
||||
return airport;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AirportsController } from './airports.controller';
|
||||
import { AirportsService } from './airports.service';
|
||||
|
||||
/** Airports domain (L2 leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [AirportsController],
|
||||
providers: [AirportsService],
|
||||
})
|
||||
export class AirportsModule {}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Airport } from '@trek/shared';
|
||||
import { searchAirports, findByIata } from '../../services/airportService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing airport service. It delegates to the
|
||||
* same `searchAirports` / `findByIata` functions the legacy route uses, so the
|
||||
* in-memory dataset and lookup behaviour stay identical and unduplicated.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AirportsService {
|
||||
search(query: string): Airport[] {
|
||||
return searchAirports(query) as Airport[];
|
||||
}
|
||||
|
||||
findByIata(code: string): Airport | null {
|
||||
return findByIata(code) as Airport | null;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,55 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
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 { AirportsModule } from './airports/airports.module';
|
||||
import { ConfigModule } from './config/config.module';
|
||||
import { SystemNoticesModule } from './system-notices/system-notices.module';
|
||||
import { MapsModule } from './maps/maps.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { AtlasModule } from './atlas/atlas.module';
|
||||
import { VacayModule } from './vacay/vacay.module';
|
||||
import { PackingModule } from './packing/packing.module';
|
||||
import { BudgetModule } from './budget/budget.module';
|
||||
import { ReservationsModule } from './reservations/reservations.module';
|
||||
import { DaysModule } from './days/days.module';
|
||||
import { AssignmentsModule } from './assignments/assignments.module';
|
||||
import { PlacesModule } from './places/places.module';
|
||||
import { TripsModule } from './trips/trips.module';
|
||||
import { TodoModule } from './todo/todo.module';
|
||||
import { CollabModule } from './collab/collab.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { PhotosModule } from './photos/photos.module';
|
||||
import { JourneyModule } from './journey/journey.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { BackupModule } from './backup/backup.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { OidcModule } from './oidc/oidc.module';
|
||||
import { OauthModule } from './oauth/oauth.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { TrekExceptionFilter } from './common/trek-exception.filter';
|
||||
import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
||||
|
||||
/**
|
||||
* Root NestJS module for the incremental migration. Domain modules
|
||||
* (weather, notifications, ...) get registered here as they are migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DatabaseModule, WeatherModule],
|
||||
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
// Global error-envelope normaliser (DI-registered so it also catches
|
||||
// framework-level exceptions like the not-found handler).
|
||||
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
|
||||
// Replays the X-Idempotency-Key the client sends on every write, matching
|
||||
// the legacy applyIdempotency middleware so retried mutations don't double-apply.
|
||||
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { AssignmentsService } from './assignments.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
type Trip = NonNullable<ReturnType<AssignmentsService['verifyTripAccess']>>;
|
||||
|
||||
/** Shared trip-access guard (mirrors requireTripAccess → 404 "Trip not found"). */
|
||||
function requireTrip(svc: AssignmentsService, tripId: string, user: User): Trip {
|
||||
const trip = svc.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
function requireEdit(svc: AssignmentsService, trip: Trip, user: User): void {
|
||||
if (!svc.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/days/:dayId/assignments — the day's ordered itinerary items.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/assignments.ts):
|
||||
* trip access (404), 'day_edit' on mutations (403, GET is access-only), create
|
||||
* 201 / rest 200, the bespoke "Day not found" / "Place not found" / "Assignment
|
||||
* not found" bodies, the journey place-created hook, and WebSocket broadcasts.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/days/:dayId/assignments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DayAssignmentsController {
|
||||
constructor(private readonly assignments: AssignmentsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
|
||||
requireTrip(this.assignments, tripId, user);
|
||||
if (!this.assignments.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
return { assignments: this.assignments.listDayAssignments(dayId) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Body() body: { place_id?: unknown; notes?: string | null },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
if (!this.assignments.placeExists(body.place_id, tripId)) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
}
|
||||
const assignment = this.assignments.createAssignment(dayId, body.place_id, body.notes);
|
||||
this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId);
|
||||
this.assignments.notifyPlaceCreated(tripId, body.place_id);
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Body('orderedIds') orderedIds: number[],
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
this.assignments.reorderAssignments(dayId, orderedIds);
|
||||
this.assignments.broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.assignmentExistsInDay(id, dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
this.assignments.deleteAssignment(id);
|
||||
this.assignments.broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/assignments/:id/* — per-assignment ops (move, time,
|
||||
* participants), independent of the day path. Same parity rules as above.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/assignments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AssignmentOpsController {
|
||||
constructor(private readonly assignments: AssignmentsService) {}
|
||||
|
||||
@Put(':id/move')
|
||||
move(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { new_day_id?: unknown; order_index?: number },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
const existing = this.assignments.getAssignmentForTrip(id, tripId);
|
||||
if (!existing) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
if (!this.assignments.dayExists(String(body.new_day_id), tripId)) {
|
||||
throw new HttpException({ error: 'Target day not found' }, 404);
|
||||
}
|
||||
const oldDayId = (existing as { day_id: number }).day_id;
|
||||
const { assignment } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index, oldDayId);
|
||||
this.assignments.broadcast(tripId, 'assignment:moved', { assignment, oldDayId: Number(oldDayId), newDayId: Number(body.new_day_id) }, socketId);
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@Get(':id/participants')
|
||||
participants(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
requireTrip(this.assignments, tripId, user);
|
||||
return { participants: this.assignments.getParticipants(id) };
|
||||
}
|
||||
|
||||
@Put(':id/time')
|
||||
time(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { place_time?: string | null; end_time?: string | null },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
const assignment = this.assignments.updateTime(id, body.place_time, body.end_time);
|
||||
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@Put(':id/participants')
|
||||
setParticipants(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body('user_ids') userIds: unknown,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!Array.isArray(userIds)) {
|
||||
throw new HttpException({ error: 'user_ids must be an array' }, 400);
|
||||
}
|
||||
const participants = this.assignments.setParticipants(id, userIds);
|
||||
this.assignments.broadcast(tripId, 'assignment:participants', { assignmentId: Number(id), participants }, socketId);
|
||||
return { participants };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DayAssignmentsController, AssignmentOpsController } from './assignments.controller';
|
||||
import { AssignmentsService } from './assignments.service';
|
||||
|
||||
/**
|
||||
* Assignments domain (S7 — Phase 2 trip sub-domain). The day-assignments mount
|
||||
* sits under the /api/trips/:tripId/days prefix (S6); the per-assignment ops use
|
||||
* the /api/trips/:tripId/assignments prefix.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [DayAssignmentsController, AssignmentOpsController],
|
||||
providers: [AssignmentsService],
|
||||
})
|
||||
export class AssignmentsModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/assignmentService';
|
||||
import { onPlaceCreated } from '../../services/journeyService';
|
||||
|
||||
type Trip = { user_id: number };
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing assignment service. Trip access mirrors
|
||||
* the requireTripAccess middleware (canAccessTrip); mutations use 'day_edit'.
|
||||
* The SQL, the move/reorder logic and the journey "place created" hook reuse the
|
||||
* legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AssignmentsService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return canAccessTrip(Number(tripId), userId) as Trip | null | undefined;
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
dayExists(dayId: string, tripId: string) {
|
||||
return svc.dayExists(dayId, tripId);
|
||||
}
|
||||
|
||||
placeExists(placeId: unknown, tripId: string) {
|
||||
return svc.placeExists(placeId as never, tripId);
|
||||
}
|
||||
|
||||
listDayAssignments(dayId: string) {
|
||||
return svc.listDayAssignments(dayId);
|
||||
}
|
||||
|
||||
createAssignment(dayId: string, placeId: unknown, notes?: string | null) {
|
||||
return svc.createAssignment(dayId, placeId as never, notes as never);
|
||||
}
|
||||
|
||||
/** Mirrors the legacy POST hook; non-fatal, like the route's try/catch. */
|
||||
notifyPlaceCreated(tripId: string, placeId: unknown): void {
|
||||
try { onPlaceCreated(Number(tripId), Number(placeId)); } catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
assignmentExistsInDay(id: string, dayId: string, tripId: string) {
|
||||
return svc.assignmentExistsInDay(id, dayId, tripId);
|
||||
}
|
||||
|
||||
deleteAssignment(id: string): void {
|
||||
svc.deleteAssignment(id);
|
||||
}
|
||||
|
||||
reorderAssignments(dayId: string, orderedIds: number[]): void {
|
||||
svc.reorderAssignments(dayId, orderedIds as never);
|
||||
}
|
||||
|
||||
getAssignmentForTrip(id: string, tripId: string) {
|
||||
return svc.getAssignmentForTrip(id, tripId);
|
||||
}
|
||||
|
||||
moveAssignment(id: string, newDayId: unknown, orderIndex: number | undefined, oldDayId: unknown) {
|
||||
return svc.moveAssignment(id, newDayId as never, orderIndex as never, oldDayId as never);
|
||||
}
|
||||
|
||||
getParticipants(id: string) {
|
||||
return svc.getParticipants(id);
|
||||
}
|
||||
|
||||
updateTime(id: string, placeTime: unknown, endTime: unknown) {
|
||||
return svc.updateTime(id, placeTime as never, endTime as never);
|
||||
}
|
||||
|
||||
setParticipants(id: string, userIds: number[]) {
|
||||
return svc.setParticipants(id, userIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Header,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { RegionGeo } from '@trek/shared';
|
||||
import type { User } from '../../types';
|
||||
import { AtlasService } from './atlas.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/addons/atlas — visited countries/regions, region GeoJSON, bucket list.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/atlas.ts): all
|
||||
* endpoints require auth; country/region codes are upper-cased; /regions is
|
||||
* always no-store while /regions/geo is cached for a day only on a non-empty
|
||||
* result; the mark POSTs answer 200 (not Nest's default 201); and the bespoke
|
||||
* 400/404 bodies are reproduced exactly. No addon gate — the legacy route has
|
||||
* none, so adding one would break clients when the addon is off.
|
||||
*/
|
||||
@Controller('api/addons/atlas')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AtlasController {
|
||||
constructor(private readonly atlas: AtlasService) {}
|
||||
|
||||
@Get('stats')
|
||||
stats(@CurrentUser() user: User) {
|
||||
return this.atlas.stats(user.id);
|
||||
}
|
||||
|
||||
@Get('regions')
|
||||
@Header('Cache-Control', 'no-cache, no-store')
|
||||
regions(@CurrentUser() user: User) {
|
||||
return this.atlas.visitedRegions(user.id);
|
||||
}
|
||||
|
||||
@Get('regions/geo')
|
||||
async regionGeo(
|
||||
@Query('countries') countries: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<RegionGeo> {
|
||||
const list = (countries || '').split(',').filter(Boolean);
|
||||
if (list.length === 0) {
|
||||
return { type: 'FeatureCollection', features: [] };
|
||||
}
|
||||
const geo = await this.atlas.regionGeo(list);
|
||||
// Cache only a non-empty result, matching the legacy route (the empty
|
||||
// short-circuit above sends no Cache-Control header).
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
return geo;
|
||||
}
|
||||
|
||||
@Get('country/:code')
|
||||
countryPlaces(@CurrentUser() user: User, @Param('code') code: string) {
|
||||
return this.atlas.countryPlaces(user.id, code.toUpperCase());
|
||||
}
|
||||
|
||||
@Post('country/:code/mark')
|
||||
@HttpCode(200)
|
||||
markCountry(@CurrentUser() user: User, @Param('code') code: string): { success: boolean } {
|
||||
this.atlas.markCountry(user.id, code.toUpperCase());
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('country/:code/mark')
|
||||
unmarkCountry(@CurrentUser() user: User, @Param('code') code: string): { success: boolean } {
|
||||
this.atlas.unmarkCountry(user.id, code.toUpperCase());
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('region/:code/mark')
|
||||
@HttpCode(200)
|
||||
markRegion(
|
||||
@CurrentUser() user: User,
|
||||
@Param('code') code: string,
|
||||
@Body('name') name?: string,
|
||||
@Body('country_code') countryCode?: string,
|
||||
): { success: boolean } {
|
||||
if (!name || !countryCode) {
|
||||
throw new HttpException({ error: 'name and country_code are required' }, 400);
|
||||
}
|
||||
this.atlas.markRegion(user.id, code.toUpperCase(), name, countryCode.toUpperCase());
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('region/:code/mark')
|
||||
unmarkRegion(@CurrentUser() user: User, @Param('code') code: string): { success: boolean } {
|
||||
this.atlas.unmarkRegion(user.id, code.toUpperCase());
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('bucket-list')
|
||||
bucketList(@CurrentUser() user: User) {
|
||||
return { items: this.atlas.bucketList(user.id) };
|
||||
}
|
||||
|
||||
@Post('bucket-list')
|
||||
createBucketItem(
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: { name?: string; lat?: number | null; lng?: number | null; country_code?: string | null; notes?: string | null; target_date?: string | null },
|
||||
): { item: unknown } {
|
||||
if (!body.name?.trim()) {
|
||||
throw new HttpException({ error: 'Name is required' }, 400);
|
||||
}
|
||||
const { name, lat, lng, country_code, notes, target_date } = body;
|
||||
return { item: this.atlas.createBucketItem(user.id, { name, lat, lng, country_code, notes, target_date }) };
|
||||
}
|
||||
|
||||
@Put('bucket-list/:id')
|
||||
updateBucketItem(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; notes?: string; lat?: number | null; lng?: number | null; country_code?: string | null; target_date?: string | null },
|
||||
): { item: unknown } {
|
||||
const { name, notes, lat, lng, country_code, target_date } = body;
|
||||
const item = this.atlas.updateBucketItem(user.id, id, { name, notes, lat, lng, country_code, target_date });
|
||||
if (!item) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
return { item };
|
||||
}
|
||||
|
||||
@Delete('bucket-list/:id')
|
||||
deleteBucketItem(@CurrentUser() user: User, @Param('id') id: string): { success: boolean } {
|
||||
if (!this.atlas.deleteBucketItem(user.id, id)) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AtlasController } from './atlas.controller';
|
||||
import { AtlasService } from './atlas.service';
|
||||
|
||||
/** Atlas addon domain (L7 leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [AtlasController],
|
||||
providers: [AtlasService],
|
||||
})
|
||||
export class AtlasModule {}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
getStats,
|
||||
getCountryPlaces,
|
||||
markCountryVisited,
|
||||
unmarkCountryVisited,
|
||||
markRegionVisited,
|
||||
unmarkRegionVisited,
|
||||
getVisitedRegions,
|
||||
getRegionGeo,
|
||||
listBucketList,
|
||||
createBucketItem,
|
||||
updateBucketItem,
|
||||
deleteBucketItem,
|
||||
} from '../../services/atlasService';
|
||||
|
||||
type CreateBucketData = Parameters<typeof createBucketItem>[1];
|
||||
type UpdateBucketData = Parameters<typeof updateBucketItem>[2];
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing atlas service. The Admin-1 GeoJSON
|
||||
* cache, the stats aggregation and the visited-region logic all stay in
|
||||
* atlasService, so behaviour is unchanged. Returns native service shapes; the
|
||||
* client-facing contracts live in @trek/shared.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AtlasService {
|
||||
stats(userId: number) {
|
||||
return getStats(userId);
|
||||
}
|
||||
|
||||
visitedRegions(userId: number) {
|
||||
return getVisitedRegions(userId);
|
||||
}
|
||||
|
||||
regionGeo(countries: string[]) {
|
||||
return getRegionGeo(countries);
|
||||
}
|
||||
|
||||
countryPlaces(userId: number, code: string) {
|
||||
return getCountryPlaces(userId, code);
|
||||
}
|
||||
|
||||
markCountry(userId: number, code: string): void {
|
||||
markCountryVisited(userId, code);
|
||||
}
|
||||
|
||||
unmarkCountry(userId: number, code: string): void {
|
||||
unmarkCountryVisited(userId, code);
|
||||
}
|
||||
|
||||
markRegion(userId: number, code: string, name: string, countryCode: string): void {
|
||||
markRegionVisited(userId, code, name, countryCode);
|
||||
}
|
||||
|
||||
unmarkRegion(userId: number, code: string): void {
|
||||
unmarkRegionVisited(userId, code);
|
||||
}
|
||||
|
||||
bucketList(userId: number) {
|
||||
return listBucketList(userId);
|
||||
}
|
||||
|
||||
createBucketItem(userId: number, data: CreateBucketData) {
|
||||
return createBucketItem(userId, data);
|
||||
}
|
||||
|
||||
updateBucketItem(userId: number, itemId: string, data: UpdateBucketData) {
|
||||
return updateBucketItem(userId, itemId, data);
|
||||
}
|
||||
|
||||
deleteBucketItem(userId: number, itemId: string): boolean {
|
||||
return deleteBucketItem(userId, itemId);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { User } from '../../types';
|
||||
|
||||
/**
|
||||
* Mirrors the legacy `adminOnly` middleware: requires an authenticated admin.
|
||||
@@ -10,7 +9,7 @@ import type { User } from '../../types';
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest<Request & { user?: User }>();
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
throw new HttpException({ error: 'Admin access required' }, 403);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RateLimitService } from './rate-limit.service';
|
||||
import { OptionalJwtGuard } from './optional-jwt.guard';
|
||||
import { writeAudit, getClientIp } from '../../services/auditLog';
|
||||
import type { User } from '../../types';
|
||||
|
||||
const WINDOW = 15 * 60 * 1000;
|
||||
const LOGIN_MIN_LATENCY_MS = 350;
|
||||
const FORGOT_MIN_LATENCY_MS = 350;
|
||||
const GENERIC_FORGOT_RESPONSE = { ok: true };
|
||||
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Public auth endpoints (no session required) — byte-identical to the legacy
|
||||
* Express route (server/src/routes/auth.ts): the same per-IP rate-limit buckets
|
||||
* + limits, the constant-time login/forgot latency padding, the enumeration-safe
|
||||
* forgot response, the audit writes and the JWT httpOnly cookie set/clear via
|
||||
* the shared cookie service (no new token shape).
|
||||
*/
|
||||
@Controller('api/auth')
|
||||
export class AuthPublicController {
|
||||
constructor(private readonly auth: AuthService, private readonly rl: RateLimitService) {}
|
||||
|
||||
private limit(bucket: string, req: Request, max: number): void {
|
||||
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
|
||||
throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('app-config')
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
appConfig(@Req() req: Request) {
|
||||
return this.auth.getAppConfig((req.user as User | undefined) ?? undefined);
|
||||
}
|
||||
|
||||
@Post('demo-login')
|
||||
@HttpCode(200)
|
||||
demoLogin(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const result = this.auth.demoLogin();
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
@Get('invite/:token')
|
||||
invite(@Param('token') token: string, @Req() req: Request) {
|
||||
this.limit('login', req, 10);
|
||||
const result = this.auth.validateInviteToken(token);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at };
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@HttpCode(201)
|
||||
register(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.limit('login', req, 10);
|
||||
const result = this.auth.registerUser(body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
async login(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.limit('login', req, 10);
|
||||
const started = Date.now();
|
||||
const result = this.auth.loginUser(body);
|
||||
if (result.auditAction) {
|
||||
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 delay(LOGIN_MIN_LATENCY_MS - elapsed);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
if (result.mfa_required) {
|
||||
return { mfa_required: true, mfa_token: result.mfa_token };
|
||||
}
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
@Post('forgot-password')
|
||||
@HttpCode(200)
|
||||
async forgotPassword(@Body() body: { email?: unknown }, @Req() req: Request) {
|
||||
this.limit('forgot', req, 3);
|
||||
const started = Date.now();
|
||||
const rawEmail = typeof body?.email === 'string' ? body.email : '';
|
||||
const ip = getClientIp(req);
|
||||
|
||||
const outcome = this.auth.requestPasswordReset(rawEmail, ip);
|
||||
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
|
||||
const origin = this.auth.getAppUrl();
|
||||
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
|
||||
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
|
||||
try {
|
||||
const delivery = await this.auth.sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
|
||||
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
|
||||
} catch {
|
||||
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 } });
|
||||
}
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed < FORGOT_MIN_LATENCY_MS) await delay(FORGOT_MIN_LATENCY_MS - elapsed);
|
||||
return GENERIC_FORGOT_RESPONSE;
|
||||
}
|
||||
|
||||
@Post('reset-password')
|
||||
@HttpCode(200)
|
||||
resetPassword(@Body() body: unknown, @Req() req: Request) {
|
||||
const ip = getClientIp(req);
|
||||
const result = this.auth.resetPassword(body);
|
||||
if (result.error) {
|
||||
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
if (result.mfa_required) {
|
||||
return { mfa_required: true };
|
||||
}
|
||||
writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('mfa/verify-login')
|
||||
@HttpCode(200)
|
||||
verifyMfaLogin(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.limit('mfa', req, 5);
|
||||
const result = this.auth.verifyMfaLogin(body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.auth.clearAuthCookie(res, req);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import type { Request } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RateLimitService } from './rate-limit.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
import { writeAudit, getClientIp } from '../../services/auditLog';
|
||||
import { isDemoEmail } from '../../services/demo';
|
||||
import type { User } from '../../types';
|
||||
|
||||
const WINDOW = 15 * 60 * 1000;
|
||||
const avatarDir = path.join(__dirname, '../../../uploads/avatars');
|
||||
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
const AVATAR_UPLOAD = {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => { if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true }); cb(null, avatarDir); },
|
||||
filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname)),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
|
||||
const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err, false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticated account endpoints — byte-identical to the legacy Express route
|
||||
* (server/src/routes/auth.ts): the same /me/* account ops, avatar upload (with
|
||||
* the demo-mode block), settings, key validation, MFA setup/enable/disable, MCP
|
||||
* tokens and the short-lived ws/resource tokens. The per-IP rate limits reuse
|
||||
* the shared buckets (the inline rateLimiter(5) shares the 'login' bucket, as in
|
||||
* the legacy code). create-token answers 201; everything else 200.
|
||||
*/
|
||||
@Controller('api/auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService, private readonly rl: RateLimitService) {}
|
||||
|
||||
private limit(bucket: string, req: Request, max: number): void {
|
||||
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
|
||||
throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: User) {
|
||||
const loaded = this.auth.getCurrentUser(user.id);
|
||||
if (!loaded) {
|
||||
throw new HttpException({ error: 'User not found' }, 404);
|
||||
}
|
||||
return { user: loaded };
|
||||
}
|
||||
|
||||
@Put('me/password')
|
||||
changePassword(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
this.limit('login', req, 5);
|
||||
const result = this.auth.changePassword(user.id, user.email, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('me')
|
||||
deleteAccount(@CurrentUser() user: User, @Req() req: Request) {
|
||||
const result = this.auth.deleteAccount(user.id, user.email, user.role);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put('me/maps-key')
|
||||
mapsKey(@CurrentUser() user: User, @Body() body: { maps_api_key?: unknown }) {
|
||||
return this.auth.updateMapsKey(user.id, body.maps_api_key);
|
||||
}
|
||||
|
||||
@Put('me/api-keys')
|
||||
apiKeys(@CurrentUser() user: User, @Body() body: unknown) {
|
||||
return this.auth.updateApiKeys(user.id, body);
|
||||
}
|
||||
|
||||
@Put('me/settings')
|
||||
updateSettings(@CurrentUser() user: User, @Body() body: unknown) {
|
||||
const result = this.auth.updateSettings(user.id, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { success: result.success, user: result.user };
|
||||
}
|
||||
|
||||
@Get('me/settings')
|
||||
getSettings(@CurrentUser() user: User) {
|
||||
const result = this.auth.getSettings(user.id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { settings: result.settings };
|
||||
}
|
||||
|
||||
@Post('avatar')
|
||||
@HttpCode(200)
|
||||
@UseInterceptors(FileInterceptor('avatar', AVATAR_UPLOAD))
|
||||
async avatar(@CurrentUser() user: User, @UploadedFile() file: Express.Multer.File | undefined) {
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
|
||||
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
|
||||
}
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No image uploaded' }, 400);
|
||||
}
|
||||
return this.auth.saveAvatar(user.id, file.filename);
|
||||
}
|
||||
|
||||
@Delete('avatar')
|
||||
async deleteAvatar(@CurrentUser() user: User) {
|
||||
return this.auth.deleteAvatar(user.id);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
users(@CurrentUser() user: User) {
|
||||
return { users: this.auth.listUsers(user.id) };
|
||||
}
|
||||
|
||||
@Get('validate-keys')
|
||||
async validateKeys(@CurrentUser() user: User) {
|
||||
const result = await this.auth.validateKeys(user.id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { maps: result.maps, weather: result.weather, maps_details: result.maps_details };
|
||||
}
|
||||
|
||||
@Get('app-settings')
|
||||
getAppSettings(@CurrentUser() user: User) {
|
||||
const result = this.auth.getAppSettings(user.id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@Put('app-settings')
|
||||
updateAppSettings(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = this.auth.updateAppSettings(user.id, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('travel-stats')
|
||||
travelStats(@CurrentUser() user: User) {
|
||||
return this.auth.getTravelStats(user.id);
|
||||
}
|
||||
|
||||
@Post('mfa/setup')
|
||||
@HttpCode(200)
|
||||
async mfaSetup(@CurrentUser() user: User) {
|
||||
const result = this.auth.setupMfa(user.id, user.email);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
try {
|
||||
const qr_svg = await result.qrPromise!;
|
||||
return { secret: result.secret, otpauth_url: result.otpauth_url, qr_svg };
|
||||
} catch (err) {
|
||||
console.error('[MFA] QR code generation error:', err);
|
||||
throw new HttpException({ error: 'Could not generate QR code' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('mfa/enable')
|
||||
@HttpCode(200)
|
||||
mfaEnable(@CurrentUser() user: User, @Body() body: { code?: unknown }, @Req() req: Request) {
|
||||
this.limit('mfa', req, 5);
|
||||
const result = this.auth.enableMfa(user.id, body.code);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
||||
return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes };
|
||||
}
|
||||
|
||||
@Post('mfa/disable')
|
||||
@HttpCode(200)
|
||||
mfaDisable(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
this.limit('login', req, 5);
|
||||
const result = this.auth.disableMfa(user.id, user.email, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
||||
return { success: true, mfa_enabled: result.mfa_enabled };
|
||||
}
|
||||
|
||||
@Get('mcp-tokens')
|
||||
listMcpTokens(@CurrentUser() user: User) {
|
||||
return { tokens: this.auth.listMcpTokens(user.id) };
|
||||
}
|
||||
|
||||
@Post('mcp-tokens')
|
||||
@HttpCode(201)
|
||||
createMcpToken(@CurrentUser() user: User, @Body() body: { name?: unknown }, @Req() req: Request) {
|
||||
this.limit('login', req, 5);
|
||||
const result = this.auth.createMcpToken(user.id, body.name);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { token: result.token };
|
||||
}
|
||||
|
||||
@Delete('mcp-tokens/:id')
|
||||
deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
const result = this.auth.deleteMcpToken(user.id, id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('ws-token')
|
||||
@HttpCode(200)
|
||||
wsToken(@CurrentUser() user: User) {
|
||||
const result = this.auth.createWsToken(user.id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { token: result.token };
|
||||
}
|
||||
|
||||
@Post('resource-token')
|
||||
@HttpCode(200)
|
||||
resourceToken(@CurrentUser() user: User, @Body() body: { purpose?: unknown }) {
|
||||
const token = this.auth.createResourceToken(user.id, body.purpose);
|
||||
if (!token) {
|
||||
throw new HttpException({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthPublicController } from './auth-public.controller';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RateLimitService } from './rate-limit.service';
|
||||
|
||||
/**
|
||||
* Auth module — public flows (login/register/reset/mfa-verify/logout) and the
|
||||
* authenticated account/MFA/token endpoints. The OIDC sub-mount (/api/auth/oidc)
|
||||
* is a separate, not-yet-migrated route, so the strangler lists the auth
|
||||
* sub-paths explicitly rather than claiming all of /api/auth.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AuthPublicController, AuthController],
|
||||
providers: [AuthService, RateLimitService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as auth from '../../services/authService';
|
||||
import { setAuthCookie, clearAuthCookie } from '../../services/cookie';
|
||||
import { sendPasswordResetEmail, getAppUrl } from '../../services/notifications';
|
||||
import type { User } from '../../types';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing auth service. Token generation, the
|
||||
* password/MFA/backup-code crypto, the JWT cookie set/clear and the reset-email
|
||||
* delivery all reuse the legacy code unchanged. Access control + audit stay in
|
||||
* the controller (mirroring the legacy route handlers).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
// Cookie
|
||||
setAuthCookie(res: Response, token: string, req: Request) { setAuthCookie(res, token, req); }
|
||||
clearAuthCookie(res: Response, req: Request) { clearAuthCookie(res, req); }
|
||||
|
||||
// Reset-email delivery (canonical app URL, never request headers)
|
||||
getAppUrl() { return getAppUrl(); }
|
||||
sendPasswordResetEmail(email: string, url: string, userId: number | null) { return sendPasswordResetEmail(email, url, userId); }
|
||||
|
||||
// Public config + auth flows
|
||||
getAppConfig(user: User | undefined) { return auth.getAppConfig(user); }
|
||||
demoLogin() { return auth.demoLogin(); }
|
||||
validateInviteToken(token: string) { return auth.validateInviteToken(token); }
|
||||
registerUser(body: unknown) { return auth.registerUser(body as Parameters<typeof auth.registerUser>[0]); }
|
||||
loginUser(body: unknown) { return auth.loginUser(body as Parameters<typeof auth.loginUser>[0]); }
|
||||
requestPasswordReset(email: string, ip: string) { return auth.requestPasswordReset(email, ip); }
|
||||
resetPassword(body: unknown) { return auth.resetPassword(body as Parameters<typeof auth.resetPassword>[0]); }
|
||||
verifyMfaLogin(body: unknown) { return auth.verifyMfaLogin(body as Parameters<typeof auth.verifyMfaLogin>[0]); }
|
||||
|
||||
// Account
|
||||
getCurrentUser(userId: number) { return auth.getCurrentUser(userId); }
|
||||
changePassword(userId: number, email: string, body: unknown) { return auth.changePassword(userId, email, body as Parameters<typeof auth.changePassword>[2]); }
|
||||
deleteAccount(userId: number, email: string, role: string) { return auth.deleteAccount(userId, email, role); }
|
||||
updateMapsKey(userId: number, key: unknown) { return auth.updateMapsKey(userId, key as string); }
|
||||
updateApiKeys(userId: number, body: unknown) { return auth.updateApiKeys(userId, body as Parameters<typeof auth.updateApiKeys>[1]); }
|
||||
updateSettings(userId: number, body: unknown) { return auth.updateSettings(userId, body as Parameters<typeof auth.updateSettings>[1]); }
|
||||
getSettings(userId: number) { return auth.getSettings(userId); }
|
||||
saveAvatar(userId: number, filename: string) { return auth.saveAvatar(userId, filename); }
|
||||
deleteAvatar(userId: number) { return auth.deleteAvatar(userId); }
|
||||
listUsers(userId: number) { return auth.listUsers(userId); }
|
||||
validateKeys(userId: number) { return auth.validateKeys(userId); }
|
||||
getAppSettings(userId: number) { return auth.getAppSettings(userId); }
|
||||
updateAppSettings(userId: number, body: unknown) { return auth.updateAppSettings(userId, body as Parameters<typeof auth.updateAppSettings>[1]); }
|
||||
getTravelStats(userId: number) { return auth.getTravelStats(userId); }
|
||||
|
||||
// MFA
|
||||
setupMfa(userId: number, email: string) { return auth.setupMfa(userId, email); }
|
||||
enableMfa(userId: number, code: unknown) { return auth.enableMfa(userId, code as string); }
|
||||
disableMfa(userId: number, email: string, body: unknown) { return auth.disableMfa(userId, email, body as Parameters<typeof auth.disableMfa>[2]); }
|
||||
|
||||
// MCP tokens + short-lived tokens
|
||||
listMcpTokens(userId: number) { return auth.listMcpTokens(userId); }
|
||||
createMcpToken(userId: number, name: unknown) { return auth.createMcpToken(userId, name as string); }
|
||||
deleteMcpToken(userId: number, id: string) { return auth.deleteMcpToken(userId, id); }
|
||||
createWsToken(userId: number) { return auth.createWsToken(userId); }
|
||||
createResourceToken(userId: number, purpose: unknown) { return auth.createResourceToken(userId, purpose as string); }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { verifyJwtAndLoadUser } from '../../middleware/auth';
|
||||
|
||||
/**
|
||||
* Mirrors the legacy `requireCookieAuth` middleware: accepts ONLY the httpOnly
|
||||
* trek_session cookie (never a Bearer token), so CSRF-sensitive state-changing
|
||||
* OAuth endpoints (consent submit, client/session mutations) can't be driven by
|
||||
* a leaked Bearer. Error bodies + codes match the legacy 401 shapes exactly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CookieAuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest<Request & { cookies?: Record<string, string> }>();
|
||||
const cookieToken = req.cookies?.trek_session;
|
||||
if (!cookieToken) {
|
||||
throw new HttpException({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
const user = verifyJwtAndLoadUser(cookieToken);
|
||||
if (!user) {
|
||||
throw new HttpException({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
req.user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export class JwtAuthGuard implements CanActivate {
|
||||
if (!user) {
|
||||
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
(req as Request & { user?: unknown }).user = user;
|
||||
req.user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
|
||||
|
||||
/**
|
||||
* Mirrors the legacy `optionalAuth` middleware: populates req.user with the
|
||||
* loaded user when a valid token is present, otherwise leaves it null — and
|
||||
* always allows the request through (never 401). Used for endpoints whose
|
||||
* response varies by auth state but don't require it (e.g. /app-config).
|
||||
*/
|
||||
@Injectable()
|
||||
export class OptionalJwtGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const token = extractToken(req);
|
||||
(req as { user: unknown }).user = (token ? verifyJwtAndLoadUser(token) : null) || null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
interface Attempt { count: number; first: number }
|
||||
|
||||
/**
|
||||
* In-memory per-IP rate limiter, ported 1:1 from the legacy auth route's
|
||||
* `rateLimiter`. Each named bucket keeps its own attempt map; `check` returns
|
||||
* false once a key exceeds `max` within `windowMs` (the caller answers 429).
|
||||
*
|
||||
* The legacy route also ran a setInterval to garbage-collect expired records;
|
||||
* that was pure memory housekeeping (the window check below already treats an
|
||||
* expired record as fresh), so it is intentionally omitted — the limit
|
||||
* behaviour is identical and there's no dangling timer to leak in tests.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimitService {
|
||||
private readonly buckets = new Map<string, Map<string, Attempt>>();
|
||||
|
||||
private store(bucket: string): Map<string, Attempt> {
|
||||
let s = this.buckets.get(bucket);
|
||||
if (!s) { s = new Map(); this.buckets.set(bucket, s); }
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Returns true when the request is allowed, false when it should be rejected (429). */
|
||||
check(bucket: string, key: string, max: number, windowMs: number, now: number): boolean {
|
||||
const store = this.store(bucket);
|
||||
const record = store.get(key);
|
||||
if (record && record.count >= max && now - record.first < windowMs) {
|
||||
return false;
|
||||
}
|
||||
if (!record || now - record.first >= windowMs) {
|
||||
store.set(key, { count: 1, first: now });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Test helper: clear a bucket (mirrors the legacy exported maps used for resets). */
|
||||
reset(bucket?: string): void {
|
||||
if (bucket) this.buckets.get(bucket)?.clear();
|
||||
else this.buckets.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import type { User } from '../../types';
|
||||
import { BackupService } from './backup.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { writeAudit, getClientIp } from '../../services/auditLog';
|
||||
import { getUploadTmpDir, MAX_BACKUP_UPLOAD_SIZE } from '../../services/backupService';
|
||||
|
||||
const UPLOAD = {
|
||||
dest: getUploadTmpDir(),
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
if (file.originalname.endsWith('.zip')) return cb(null, true);
|
||||
cb(new Error('Only ZIP files allowed'), false);
|
||||
},
|
||||
limits: { fileSize: MAX_BACKUP_UPLOAD_SIZE },
|
||||
};
|
||||
|
||||
/**
|
||||
* /api/backup — admin-only database backup management (list, create, download,
|
||||
* restore from a stored or uploaded zip, auto-backup settings, delete).
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/backup.ts):
|
||||
* admin-gated, the create rate-limit (429), the filename validation (400/404),
|
||||
* the audit-log writes, res.download for downloads and the tmp-file cleanup for
|
||||
* uploads. All JSON responses answer 200.
|
||||
*/
|
||||
@Controller('api/backup')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
export class BackupController {
|
||||
constructor(private readonly backup: BackupService) {}
|
||||
|
||||
@Get('list')
|
||||
list() {
|
||||
try {
|
||||
return { backups: this.backup.listBackups() };
|
||||
} catch {
|
||||
throw new HttpException({ error: 'Error loading backups' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@HttpCode(200) // Express answers create with res.json (200), not the POST-default 201.
|
||||
async create(@CurrentUser() user: User, @Req() req: Request) {
|
||||
if (!this.backup.checkRateLimit(req.ip || 'unknown', 3, this.backup.rateWindow)) {
|
||||
throw new HttpException({ error: 'Too many backup requests. Please try again later.' }, 429);
|
||||
}
|
||||
try {
|
||||
const backup = await this.backup.createBackup();
|
||||
writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } });
|
||||
return { success: true, backup };
|
||||
} catch {
|
||||
throw new HttpException({ error: 'Error creating backup' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('download/:filename')
|
||||
download(@Param('filename') filename: string, @Res() res: Response): void {
|
||||
if (!this.backup.isValidBackupFilename(filename)) {
|
||||
throw new HttpException({ error: 'Invalid filename' }, 400);
|
||||
}
|
||||
if (!this.backup.backupFileExists(filename)) {
|
||||
throw new HttpException({ error: 'Backup not found' }, 404);
|
||||
}
|
||||
res.download(this.backup.backupFilePath(filename), filename);
|
||||
}
|
||||
|
||||
@Post('restore/:filename')
|
||||
@HttpCode(200) // Express answers restore with res.json (200).
|
||||
async restore(@CurrentUser() user: User, @Param('filename') filename: string, @Req() req: Request) {
|
||||
if (!this.backup.isValidBackupFilename(filename)) {
|
||||
throw new HttpException({ error: 'Invalid filename' }, 400);
|
||||
}
|
||||
if (!this.backup.backupFileExists(filename)) {
|
||||
throw new HttpException({ error: 'Backup not found' }, 404);
|
||||
}
|
||||
try {
|
||||
const result = await this.backup.restoreFromZip(this.backup.backupFilePath(filename));
|
||||
if (!result.success) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
throw new HttpException({ error: 'Error restoring backup' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('upload-restore')
|
||||
@HttpCode(200) // Express answers upload-restore with res.json (200).
|
||||
@UseInterceptors(FileInterceptor('backup', UPLOAD))
|
||||
async uploadRestore(@CurrentUser() user: User, @UploadedFile() file: Express.Multer.File | undefined, @Req() req: Request) {
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
const zipPath = file.path;
|
||||
const origName = file.originalname || 'upload.zip';
|
||||
try {
|
||||
const result = await this.backup.restoreFromZip(zipPath);
|
||||
if (!result.success) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
throw new HttpException({ error: 'Error restoring backup' }, 500);
|
||||
} finally {
|
||||
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('auto-settings')
|
||||
autoSettings() {
|
||||
try {
|
||||
return this.backup.getAutoSettings();
|
||||
} catch (err) {
|
||||
console.error('[backup] GET auto-settings:', err);
|
||||
throw new HttpException({ error: 'Could not load backup settings' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Put('auto-settings')
|
||||
updateAutoSettings(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Req() req: Request) {
|
||||
try {
|
||||
const settings = this.backup.updateAutoSettings(body || {});
|
||||
writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } });
|
||||
return { settings };
|
||||
} catch (err) {
|
||||
console.error('[backup] PUT auto-settings:', err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new HttpException({ error: 'Could not save auto-backup settings', detail: process.env.NODE_ENV?.toLowerCase() !== 'production' ? msg : undefined }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':filename')
|
||||
remove(@CurrentUser() user: User, @Param('filename') filename: string, @Req() req: Request) {
|
||||
if (!this.backup.isValidBackupFilename(filename)) {
|
||||
throw new HttpException({ error: 'Invalid filename' }, 400);
|
||||
}
|
||||
if (!this.backup.backupFileExists(filename)) {
|
||||
throw new HttpException({ error: 'Backup not found' }, 404);
|
||||
}
|
||||
this.backup.deleteBackup(filename);
|
||||
writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BackupController } from './backup.controller';
|
||||
import { BackupService } from './backup.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BackupController],
|
||||
providers: [BackupService],
|
||||
})
|
||||
export class BackupModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as svc from '../../services/backupService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing backup service. The zip packing/restore,
|
||||
* the auto-backup scheduler settings, the filename validation, the rate-limit
|
||||
* bookkeeping and the tmp-dir all reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BackupService {
|
||||
listBackups() { return svc.listBackups(); }
|
||||
createBackup() { return svc.createBackup(); }
|
||||
restoreFromZip(zipPath: string) { return svc.restoreFromZip(zipPath); }
|
||||
getAutoSettings() { return svc.getAutoSettings(); }
|
||||
updateAutoSettings(body: Record<string, unknown>) { return svc.updateAutoSettings(body); }
|
||||
deleteBackup(filename: string) { return svc.deleteBackup(filename); }
|
||||
|
||||
isValidBackupFilename(filename: string) { return svc.isValidBackupFilename(filename); }
|
||||
backupFilePath(filename: string) { return svc.backupFilePath(filename); }
|
||||
backupFileExists(filename: string) { return svc.backupFileExists(filename); }
|
||||
checkRateLimit(key: string, maxAttempts: number, windowMs: number) { return svc.checkRateLimit(key, maxAttempts, windowMs); }
|
||||
|
||||
get rateWindow() { return svc.BACKUP_RATE_WINDOW; }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/budget — trip-scoped expense planner.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/budget.ts):
|
||||
* every handler verifies trip access (404); mutations check 'budget_edit' (403);
|
||||
* create is 201, the rest 200; bespoke 400/404 bodies reproduced; mutations
|
||||
* broadcast over WebSocket with the forwarded X-Socket-Id. Static sub-routes
|
||||
* (summary, settlement, reorder/*) are declared before /:id so they win over the
|
||||
* param. Updating total_price on a reservation-linked item syncs the price back.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/budget')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BudgetController {
|
||||
constructor(private readonly budget: BudgetService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.budget.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: ReturnType<BudgetService['verifyTripAccess']>, user: User): void {
|
||||
if (!this.budget.canEdit(trip!, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { items: this.budget.list(tripId) };
|
||||
}
|
||||
|
||||
@Get('summary/per-person')
|
||||
perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { summary: this.budget.perPersonSummary(tripId) };
|
||||
}
|
||||
|
||||
@Get('settlement')
|
||||
settlement(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return this.budget.settlement(tripId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.name) {
|
||||
throw new HttpException({ error: 'Name is required' }, 400);
|
||||
}
|
||||
const item = this.budget.create(tripId, body as { name: string });
|
||||
this.budget.broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
return { item };
|
||||
}
|
||||
|
||||
@Put('reorder/items')
|
||||
reorderItems(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body('orderedIds') orderedIds: number[],
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
this.budget.reorderItems(tripId, orderedIds);
|
||||
this.budget.broadcast(tripId, 'budget:reordered', { orderedIds }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put('reorder/categories')
|
||||
reorderCategories(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body('orderedCategories') orderedCategories: string[],
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
this.budget.reorderCategories(tripId, orderedCategories);
|
||||
this.budget.broadcast(tripId, 'budget:reordered', { orderedCategories }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const updated = this.budget.update(id, tripId, body);
|
||||
if (!updated) {
|
||||
throw new HttpException({ error: 'Budget item not found' }, 404);
|
||||
}
|
||||
if (updated.reservation_id && body.total_price !== undefined) {
|
||||
this.budget.syncReservationPrice(tripId, updated.reservation_id, updated.total_price, socketId);
|
||||
}
|
||||
this.budget.broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
return { item: updated };
|
||||
}
|
||||
|
||||
@Put(':id/members')
|
||||
updateMembers(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body('user_ids') userIds: unknown,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!Array.isArray(userIds)) {
|
||||
throw new HttpException({ error: 'user_ids must be an array' }, 400);
|
||||
}
|
||||
const result = this.budget.updateMembers(id, tripId, userIds);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Budget item not found' }, 404);
|
||||
}
|
||||
this.budget.broadcast(tripId, 'budget:members-updated', { itemId: Number(id), members: result.members, persons: result.item.persons }, socketId);
|
||||
return { members: result.members, item: result.item };
|
||||
}
|
||||
|
||||
@Put(':id/members/:userId/paid')
|
||||
toggleMemberPaid(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body('paid') paid: boolean,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const member = this.budget.toggleMemberPaid(id, userId, paid);
|
||||
this.budget.broadcast(tripId, 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, socketId);
|
||||
return { member };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.budget.remove(id, tripId)) {
|
||||
throw new HttpException({ error: 'Budget item not found' }, 404);
|
||||
}
|
||||
this.budget.broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BudgetController } from './budget.controller';
|
||||
import { BudgetService } from './budget.service';
|
||||
|
||||
/** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [BudgetController],
|
||||
providers: [BudgetService],
|
||||
})
|
||||
export class BudgetModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/budgetService';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing budget service. Trip-access, the
|
||||
* 'budget_edit' permission, the SQL, settlement maths and the WebSocket
|
||||
* broadcasts all reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BudgetService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('budget_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
list(tripId: string) {
|
||||
return svc.listBudgetItems(tripId);
|
||||
}
|
||||
|
||||
perPersonSummary(tripId: string) {
|
||||
return svc.getPerPersonSummary(tripId);
|
||||
}
|
||||
|
||||
settlement(tripId: string) {
|
||||
return svc.calculateSettlement(tripId);
|
||||
}
|
||||
|
||||
create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) {
|
||||
return svc.createBudgetItem(tripId, data);
|
||||
}
|
||||
|
||||
update(id: string, tripId: string, data: Parameters<typeof svc.updateBudgetItem>[2]) {
|
||||
return svc.updateBudgetItem(id, tripId, data);
|
||||
}
|
||||
|
||||
remove(id: string, tripId: string): boolean {
|
||||
return svc.deleteBudgetItem(id, tripId);
|
||||
}
|
||||
|
||||
updateMembers(id: string, tripId: string, userIds: number[]) {
|
||||
return svc.updateMembers(id, tripId, userIds);
|
||||
}
|
||||
|
||||
toggleMemberPaid(id: string, userId: string, paid: boolean) {
|
||||
return svc.toggleMemberPaid(id, userId, paid);
|
||||
}
|
||||
|
||||
reorderItems(tripId: string, orderedIds: number[]): void {
|
||||
svc.reorderBudgetItems(tripId, orderedIds);
|
||||
}
|
||||
|
||||
reorderCategories(tripId: string, orderedCategories: string[]): void {
|
||||
svc.reorderBudgetCategories(tripId, orderedCategories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the legacy PUT /:id side effect: when a price-linked budget item's
|
||||
* total_price changes, write it into the reservation's metadata and broadcast
|
||||
* reservation:updated. Non-fatal — a failure here never breaks the budget update.
|
||||
*/
|
||||
syncReservationPrice(tripId: string, reservationId: number, totalPrice: number, socketId: string | undefined): void {
|
||||
try {
|
||||
const reservation = db.prepare(
|
||||
'SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?',
|
||||
).get(reservationId, tripId) as { id: number; metadata: string | null } | undefined;
|
||||
if (!reservation) return;
|
||||
const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
meta.price = String(totalPrice);
|
||||
db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id);
|
||||
const updatedRes = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id);
|
||||
broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, socketId);
|
||||
} catch (err) {
|
||||
console.error('[budget] Failed to sync price to reservation:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Body, Controller, Delete, Get, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import type { Category, CategoryListResponse } from '@trek/shared';
|
||||
import type { User } from '../../types';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/categories — place-category palette CRUD.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/categories.ts):
|
||||
* listing is open to any authenticated user; create/update/delete require admin
|
||||
* (JwtAuthGuard + AdminGuard). Status codes match the Nest defaults the legacy
|
||||
* route also used (201 on create, 200 elsewhere), and the bespoke 400/404 bodies
|
||||
* are reproduced exactly.
|
||||
*/
|
||||
@Controller('api/categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categories: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
list(): CategoryListResponse {
|
||||
return { categories: this.categories.list() };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Body('name') name?: string,
|
||||
@Body('color') color?: string,
|
||||
@Body('icon') icon?: string,
|
||||
): { category: Category } {
|
||||
if (!name) {
|
||||
throw new HttpException({ error: 'Category name is required' }, 400);
|
||||
}
|
||||
return { category: this.categories.create(user.id, name, color, icon) };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body('name') name?: string,
|
||||
@Body('color') color?: string,
|
||||
@Body('icon') icon?: string,
|
||||
): { category: Category } {
|
||||
if (!this.categories.getById(id)) {
|
||||
throw new HttpException({ error: 'Category not found' }, 404);
|
||||
}
|
||||
return { category: this.categories.update(id, name, color, icon) };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
remove(@Param('id') id: string): { success: boolean } {
|
||||
if (!this.categories.getById(id)) {
|
||||
throw new HttpException({ error: 'Category not found' }, 404);
|
||||
}
|
||||
this.categories.remove(id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
/** Categories domain (L4 leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Category } from '@trek/shared';
|
||||
import {
|
||||
listCategories,
|
||||
getCategoryById,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
} from '../../services/categoryService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing category service. The SQL and the
|
||||
* default colour/icon fallbacks stay in categoryService, so behaviour is
|
||||
* unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
list(): Category[] {
|
||||
return listCategories() as Category[];
|
||||
}
|
||||
|
||||
getById(id: string | number): Category | undefined {
|
||||
return getCategoryById(id) as Category | undefined;
|
||||
}
|
||||
|
||||
create(userId: number, name: string, color?: string, icon?: string): Category {
|
||||
return createCategory(userId, name, color, icon) as Category;
|
||||
}
|
||||
|
||||
update(id: string | number, name?: string, color?: string, icon?: string): Category {
|
||||
return updateCategory(id, name, color, icon) as Category;
|
||||
}
|
||||
|
||||
remove(id: string | number): void {
|
||||
deleteCategory(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { User } from '../../types';
|
||||
import { CollabService } from './collab.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { BLOCKED_EXTENSIONS } from '../../services/fileService';
|
||||
|
||||
const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024;
|
||||
const filesDir = path.join(__dirname, '../../../uploads/files');
|
||||
const NOTE_UPLOAD = {
|
||||
storage: 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)}`),
|
||||
}),
|
||||
limits: { fileSize: MAX_NOTE_FILE_SIZE },
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
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, false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/collab — shared notes, polls, chat (+ reactions), link
|
||||
* previews. WebSocket-backed group collaboration.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/collab.ts): trip
|
||||
* access (404), 'collab_edit' (403) on mutations + 'file_upload' on note files,
|
||||
* create 201 / rest 200 (vote + react POST stay 200), the bespoke 400/403/404
|
||||
* bodies, the chat/note notifications, and all WebSocket broadcasts with the
|
||||
* forwarded X-Socket-Id.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/collab')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CollabController {
|
||||
constructor(private readonly collab: CollabService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.collab.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<CollabService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.collab.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes ───────────────────────────────────────────────────────────────
|
||||
@Get('notes')
|
||||
listNotes(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { notes: this.collab.listNotes(tripId) };
|
||||
}
|
||||
|
||||
@Post('notes')
|
||||
createNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: { title?: string; content?: string; category?: string; color?: string; website?: string }, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.title) {
|
||||
throw new HttpException({ error: 'Title is required' }, 400);
|
||||
}
|
||||
const note = this.collab.createNote(tripId, user.id, {
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
category: body.category,
|
||||
color: body.color,
|
||||
website: body.website,
|
||||
});
|
||||
this.collab.broadcast(tripId, 'collab:note:created', { note }, socketId);
|
||||
this.collab.notifyCollab(tripId, user);
|
||||
return { note };
|
||||
}
|
||||
|
||||
@Put('notes/:id')
|
||||
updateNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: { title?: string; content?: string; category?: string; color?: string; pinned?: number | boolean; website?: string }, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const note = this.collab.updateNote(tripId, id, {
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
category: body.category,
|
||||
color: body.color,
|
||||
pinned: body.pinned,
|
||||
website: body.website,
|
||||
});
|
||||
if (!note) {
|
||||
throw new HttpException({ error: 'Note not found' }, 404);
|
||||
}
|
||||
this.collab.broadcast(tripId, 'collab:note:updated', { note }, socketId);
|
||||
return { note };
|
||||
}
|
||||
|
||||
@Delete('notes/:id')
|
||||
deleteNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.collab.deleteNote(tripId, id)) {
|
||||
throw new HttpException({ error: 'Note not found' }, 404);
|
||||
}
|
||||
this.collab.broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('notes/:id/files')
|
||||
@UseInterceptors(FileInterceptor('file', NOTE_UPLOAD))
|
||||
addNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.collab.canUploadFiles(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission to upload files' }, 403);
|
||||
}
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
const result = this.collab.addNoteFile(tripId, id, file);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Note not found' }, 404);
|
||||
}
|
||||
this.collab.broadcast(tripId, 'collab:note:updated', { note: this.collab.getFormattedNoteById(id) }, socketId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete('notes/:id/files/:fileId')
|
||||
deleteNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('fileId') fileId: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.collab.deleteNoteFile(id, fileId)) {
|
||||
throw new HttpException({ error: 'File not found' }, 404);
|
||||
}
|
||||
this.collab.broadcast(tripId, 'collab:note:updated', { note: this.collab.getFormattedNoteById(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Polls ───────────────────────────────────────────────────────────────
|
||||
@Get('polls')
|
||||
listPolls(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { polls: this.collab.listPolls(tripId) };
|
||||
}
|
||||
|
||||
@Post('polls')
|
||||
createPoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: { question?: string; options?: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string }, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.question) {
|
||||
throw new HttpException({ error: 'Question is required' }, 400);
|
||||
}
|
||||
if (!Array.isArray(body.options) || body.options.length < 2) {
|
||||
throw new HttpException({ error: 'At least 2 options are required' }, 400);
|
||||
}
|
||||
const poll = this.collab.createPoll(tripId, user.id, {
|
||||
question: body.question,
|
||||
options: body.options,
|
||||
multiple: body.multiple,
|
||||
multiple_choice: body.multiple_choice,
|
||||
deadline: body.deadline,
|
||||
});
|
||||
this.collab.broadcast(tripId, 'collab:poll:created', { poll }, socketId);
|
||||
return { poll };
|
||||
}
|
||||
|
||||
@Post('polls/:id/vote')
|
||||
@HttpCode(200)
|
||||
votePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body('option_index') optionIndex: number, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const result = this.collab.votePoll(tripId, id, user.id, optionIndex);
|
||||
if (result.error === 'not_found') throw new HttpException({ error: 'Poll not found' }, 404);
|
||||
if (result.error === 'closed') throw new HttpException({ error: 'Poll is closed' }, 400);
|
||||
if (result.error === 'invalid_index') throw new HttpException({ error: 'Invalid option index' }, 400);
|
||||
this.collab.broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, socketId);
|
||||
return { poll: result.poll };
|
||||
}
|
||||
|
||||
@Put('polls/:id/close')
|
||||
closePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const poll = this.collab.closePoll(tripId, id);
|
||||
if (!poll) {
|
||||
throw new HttpException({ error: 'Poll not found' }, 404);
|
||||
}
|
||||
this.collab.broadcast(tripId, 'collab:poll:closed', { poll }, socketId);
|
||||
return { poll };
|
||||
}
|
||||
|
||||
@Delete('polls/:id')
|
||||
deletePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.collab.deletePoll(tripId, id)) {
|
||||
throw new HttpException({ error: 'Poll not found' }, 404);
|
||||
}
|
||||
this.collab.broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Messages ────────────────────────────────────────────────────────────
|
||||
@Get('messages')
|
||||
listMessages(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('before') before?: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { messages: this.collab.listMessages(tripId, before) };
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: { text?: string; reply_to?: number | null }, @Headers('x-socket-id') socketId?: string) {
|
||||
if (body.text && body.text.length > 5000) {
|
||||
throw new HttpException({ error: 'text must be 5000 characters or less' }, 400);
|
||||
}
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.text || !body.text.trim()) {
|
||||
throw new HttpException({ error: 'Message text is required' }, 400);
|
||||
}
|
||||
const result = this.collab.createMessage(tripId, user.id, body.text, body.reply_to);
|
||||
if (result.error === 'reply_not_found') {
|
||||
throw new HttpException({ error: 'Reply target message not found' }, 400);
|
||||
}
|
||||
this.collab.broadcast(tripId, 'collab:message:created', { message: result.message }, socketId);
|
||||
const t = body.text.trim();
|
||||
this.collab.notifyCollab(tripId, user, t.length > 80 ? t.substring(0, 80) + '...' : t);
|
||||
return { message: result.message };
|
||||
}
|
||||
|
||||
@Post('messages/:id/react')
|
||||
@HttpCode(200)
|
||||
react(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body('emoji') emoji: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!emoji) {
|
||||
throw new HttpException({ error: 'Emoji is required' }, 400);
|
||||
}
|
||||
const result = this.collab.reactMessage(id, tripId, user.id, emoji);
|
||||
if (!result.found) {
|
||||
throw new HttpException({ error: 'Message not found' }, 404);
|
||||
}
|
||||
this.collab.broadcast(tripId, 'collab:message:reacted', { messageId: Number(id), reactions: result.reactions }, socketId);
|
||||
return { reactions: result.reactions };
|
||||
}
|
||||
|
||||
@Delete('messages/:id')
|
||||
deleteMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const result = this.collab.deleteMessage(tripId, id, user.id);
|
||||
if (result.error === 'not_found') throw new HttpException({ error: 'Message not found' }, 404);
|
||||
if (result.error === 'not_owner') throw new HttpException({ error: 'You can only delete your own messages' }, 403);
|
||||
this.collab.broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: result.username || user.username }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Link preview ──────────────────────────────────────────────────────────
|
||||
@Get('link-preview')
|
||||
async linkPreview(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('url') url?: string) {
|
||||
// NB: the legacy route does not verify trip access on link-preview; kept 1:1.
|
||||
void user; void tripId;
|
||||
if (!url) {
|
||||
throw new HttpException({ error: 'URL is required' }, 400);
|
||||
}
|
||||
try {
|
||||
const preview = await this.collab.linkPreview(url);
|
||||
const asRecord = preview as { error?: string };
|
||||
if (asRecord.error) {
|
||||
throw new HttpException({ error: asRecord.error }, 400);
|
||||
}
|
||||
return preview;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
return { title: null, description: null, image: null, url };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CollabController } from './collab.controller';
|
||||
import { CollabService } from './collab.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CollabController],
|
||||
providers: [CollabService],
|
||||
})
|
||||
export class CollabModule {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/collabService';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing collab service. Trip access, the
|
||||
* 'collab_edit' / 'file_upload' permissions, the SQL and the WebSocket
|
||||
* broadcasts reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CollabService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('collab_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
canUploadFiles(trip: Trip, user: User): boolean {
|
||||
return checkPermission('file_upload', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
listNotes(tripId: string) { return svc.listNotes(tripId); }
|
||||
createNote(tripId: string, userId: number, data: Parameters<typeof svc.createNote>[2]) { return svc.createNote(tripId, userId, data); }
|
||||
updateNote(tripId: string, id: string, data: Parameters<typeof svc.updateNote>[2]) { return svc.updateNote(tripId, id, data); }
|
||||
deleteNote(tripId: string, id: string): boolean { return svc.deleteNote(tripId, id); }
|
||||
addNoteFile(tripId: string, id: string, file: Parameters<typeof svc.addNoteFile>[2]) { return svc.addNoteFile(tripId, id, file); }
|
||||
getFormattedNoteById(id: string) { return svc.getFormattedNoteById(id); }
|
||||
deleteNoteFile(id: string, fileId: string): boolean { return svc.deleteNoteFile(id, fileId); }
|
||||
|
||||
listPolls(tripId: string) { return svc.listPolls(tripId); }
|
||||
createPoll(tripId: string, userId: number, data: Parameters<typeof svc.createPoll>[2]) { return svc.createPoll(tripId, userId, data); }
|
||||
votePoll(tripId: string, id: string, userId: number, optionIndex: number) { return svc.votePoll(tripId, id, userId, optionIndex); }
|
||||
closePoll(tripId: string, id: string) { return svc.closePoll(tripId, id); }
|
||||
deletePoll(tripId: string, id: string): boolean { return svc.deletePoll(tripId, id); }
|
||||
|
||||
listMessages(tripId: string, before?: string) { return svc.listMessages(tripId, before); }
|
||||
createMessage(tripId: string, userId: number, text: string, replyTo?: number | null) { return svc.createMessage(tripId, userId, text, replyTo); }
|
||||
deleteMessage(tripId: string, id: string, userId: number) { return svc.deleteMessage(tripId, id, userId); }
|
||||
reactMessage(id: string, tripId: string, userId: number, emoji: string) { return svc.addOrRemoveReaction(id, tripId, userId, emoji); }
|
||||
|
||||
linkPreview(url: string) { return svc.fetchLinkPreview(url); }
|
||||
|
||||
/** Fire-and-forget collab notification (mirrors the route's dynamic import). */
|
||||
notifyCollab(tripId: string, actor: User, preview?: string): void {
|
||||
import('../../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
const params: Record<string, string> = { trip: tripInfo?.title || 'Untitled', actor: actor.email, tripId: String(tripId) };
|
||||
if (preview !== undefined) params.preview = preview;
|
||||
send({ event: 'collab_message', actorId: actor.id, scope: 'trip', targetId: Number(tripId), params }).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
/**
|
||||
* Nest counterpart of the legacy `applyIdempotency` middleware
|
||||
* (server/src/middleware/idempotency.ts), which the Express `authenticate`
|
||||
* middleware runs on every authenticated request.
|
||||
*
|
||||
* The TREK client attaches an `X-Idempotency-Key` to ALL write operations (see
|
||||
* client/src/api/client.ts) and the offline sync queue replays mutations with
|
||||
* that key, so a migrated mutating route MUST honour it — otherwise a replayed
|
||||
* POST would create a duplicate instead of returning the cached response. This
|
||||
* reproduces the legacy behaviour exactly, against the same `idempotency_keys`
|
||||
* table:
|
||||
* - non-mutating method, or no key, or no authenticated user -> pass through
|
||||
* - key longer than the cap -> 400 with the exact legacy message
|
||||
* - (key, user, method, path) already stored -> replay the cached response
|
||||
* - otherwise -> capture a successful JSON response under the key
|
||||
*
|
||||
* Capturing wraps `res.json`, so 204 / `res.end()` responses are not cached —
|
||||
* matching the Express wrapper, which only fires on `res.json`.
|
||||
*/
|
||||
|
||||
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
const MAX_KEY_LENGTH = 128;
|
||||
const MAX_CACHED_BODY_BYTES = 256 * 1024;
|
||||
|
||||
interface IdempotencyRow {
|
||||
status_code: number;
|
||||
response_body: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IdempotencyInterceptor implements NestInterceptor {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest<Request & { user?: { id: number } }>();
|
||||
const res = context.switchToHttp().getResponse<Response>();
|
||||
|
||||
if (!MUTATING_METHODS.has(req.method)) return next.handle();
|
||||
|
||||
const key = req.headers['x-idempotency-key'] as string | undefined;
|
||||
if (!key) return next.handle();
|
||||
|
||||
// Idempotency only applies to authenticated requests — the legacy code runs
|
||||
// inside `authenticate`, after req.user is set.
|
||||
const userId = req.user?.id;
|
||||
if (userId == null) return next.handle();
|
||||
|
||||
if (key.length > MAX_KEY_LENGTH) {
|
||||
throw new HttpException({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' }, 400);
|
||||
}
|
||||
|
||||
// Scope the lookup by method + path as well as user, so the same key replayed
|
||||
// against a different endpoint can't return an unrelated cached body.
|
||||
const existing = this.database.get<IdempotencyRow>(
|
||||
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?',
|
||||
key, userId, req.method, req.path,
|
||||
);
|
||||
if (existing) {
|
||||
res.status(existing.status_code);
|
||||
return of(JSON.parse(existing.response_body));
|
||||
}
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
const database = this.database;
|
||||
res.json = function (body: unknown): Response {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
const serialized = JSON.stringify(body);
|
||||
if (serialized.length <= MAX_CACHED_BODY_BYTES) {
|
||||
database.run(
|
||||
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
key, userId, req.method, req.path, res.statusCode, serialized, Math.floor(Date.now() / 1000),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: if storage fails, the request still succeeds.
|
||||
}
|
||||
}
|
||||
return originalJson(body);
|
||||
};
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import type { PublicConfig } from '@trek/shared';
|
||||
import { DEFAULT_LANGUAGE } from '../../config';
|
||||
|
||||
/**
|
||||
* /api/config — public (unauthenticated) bootstrap config.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/publicConfig.ts):
|
||||
* no auth guard, returns the server's configured default language. Deliberately
|
||||
* has no service — it just surfaces a config constant, exactly like the original.
|
||||
*/
|
||||
@Controller('api/config')
|
||||
export class ConfigController {
|
||||
@Get()
|
||||
getConfig(): PublicConfig {
|
||||
return { defaultLanguage: DEFAULT_LANGUAGE };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigController } from './config.controller';
|
||||
|
||||
/** Public config domain (L2 leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [ConfigController],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { DayNotesService } from './day-notes.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };
|
||||
|
||||
// Mirrors the legacy validateStringLengths({ text: 500, time: 150 }) middleware,
|
||||
// which runs BEFORE the trip-access check — so an over-long field 400s first.
|
||||
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 150 };
|
||||
|
||||
function validateLengths(body: Record<string, unknown>): void {
|
||||
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
|
||||
const value = body[field];
|
||||
if (value && typeof value === 'string' && value.length > max) {
|
||||
throw new HttpException({ error: `${field} must be ${max} characters or less` }, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/days/:dayId/notes — free-text annotations on a day.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/dayNotes.ts):
|
||||
* the string-length guard runs first (400), then trip access (404), then the
|
||||
* 'day_edit' permission (403); create 201 / rest 200; the bespoke "Day not
|
||||
* found" / "Note not found" / "Text required" bodies; WebSocket broadcasts with
|
||||
* the forwarded X-Socket-Id.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/days/:dayId/notes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DayNotesController {
|
||||
constructor(private readonly notes: DayNotesService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.notes.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<DayNotesService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.notes.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { notes: this.notes.list(dayId, tripId) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Body() body: DayNoteBody,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
validateLengths(body);
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.notes.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
if (!body.text?.trim()) {
|
||||
throw new HttpException({ error: 'Text required' }, 400);
|
||||
}
|
||||
const note = this.notes.create(dayId, tripId, body.text, body.time, body.icon, body.sort_order);
|
||||
this.notes.broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, socketId);
|
||||
return { note };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: DayNoteBody,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
validateLengths(body);
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const current = this.notes.getNote(id, dayId, tripId);
|
||||
if (!current) {
|
||||
throw new HttpException({ error: 'Note not found' }, 404);
|
||||
}
|
||||
const note = this.notes.update(id, current as never, { text: body.text, time: body.time, icon: body.icon, sort_order: body.sort_order });
|
||||
this.notes.broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note }, socketId);
|
||||
return { note };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.notes.getNote(id, dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Note not found' }, 404);
|
||||
}
|
||||
this.notes.remove(id);
|
||||
this.notes.broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as dayNoteService from '../../services/dayNoteService';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof dayNoteService.verifyTripAccess>>;
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing day-note service. Trip access + the
|
||||
* 'day_edit' permission reuse the legacy checks; the SQL is unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DayNotesService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return dayNoteService.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
list(dayId: string, tripId: string) {
|
||||
return dayNoteService.listNotes(dayId, tripId);
|
||||
}
|
||||
|
||||
dayExists(dayId: string, tripId: string) {
|
||||
return dayNoteService.dayExists(dayId, tripId);
|
||||
}
|
||||
|
||||
getNote(id: string, dayId: string, tripId: string) {
|
||||
return dayNoteService.getNote(id, dayId, tripId);
|
||||
}
|
||||
|
||||
create(dayId: string, tripId: string, text: string, time?: string, icon?: string, sortOrder?: number) {
|
||||
return dayNoteService.createNote(dayId, tripId, text, time, icon, sortOrder);
|
||||
}
|
||||
|
||||
update(id: string, current: Parameters<typeof dayNoteService.updateNote>[1], fields: { text?: string; time?: string; icon?: string; sort_order?: number }) {
|
||||
return dayNoteService.updateNote(id, current, fields);
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
dayNoteService.deleteNote(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { DaysService } from './days.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/days — trip itinerary days.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/days.ts): trip
|
||||
* access (404 "Trip not found"), the 'day_edit' permission on mutations (403),
|
||||
* create 201 / rest 200, the bespoke 404 "Day not found", and WebSocket
|
||||
* broadcasts with the forwarded X-Socket-Id.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/days')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DaysController {
|
||||
constructor(private readonly days: DaysService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.days.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<DaysService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.days.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return this.days.list(tripId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { date?: string; notes?: string },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const day = this.days.create(tripId, body.date, body.notes);
|
||||
this.days.broadcast(tripId, 'day:created', { day }, socketId);
|
||||
return { day };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { notes?: string; title?: string | null },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const current = this.days.getDay(id, tripId);
|
||||
if (!current) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
const day = this.days.update(id, current as never, { notes: body.notes, title: body.title });
|
||||
this.days.broadcast(tripId, 'day:updated', { day }, socketId);
|
||||
return { day };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.days.getDay(id, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
this.days.remove(id);
|
||||
this.days.broadcast(tripId, 'day:deleted', { dayId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DaysController } from './days.controller';
|
||||
import { DaysService } from './days.service';
|
||||
import { DayNotesController } from './day-notes.controller';
|
||||
import { DayNotesService } from './day-notes.service';
|
||||
|
||||
/**
|
||||
* Days + day-notes domain (S6 — Phase 2 trip sub-domain). The single prefix
|
||||
* /api/trips/:tripId/days covers both the days mount and the nested
|
||||
* /days/:dayId/notes mount.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [DaysController, DayNotesController],
|
||||
providers: [DaysService, DayNotesService],
|
||||
})
|
||||
export class DaysModule {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as dayService from '../../services/dayService';
|
||||
|
||||
type Trip = { user_id: number };
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the day parts of the existing day service. Trip access
|
||||
* mirrors the requireTripAccess middleware (canAccessTrip); mutations use the
|
||||
* 'day_edit' permission. The SQL and the day/assignment shaping reuse the legacy
|
||||
* code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DaysService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return canAccessTrip(Number(tripId), userId) as Trip | null | undefined;
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
list(tripId: string) {
|
||||
return dayService.listDays(tripId);
|
||||
}
|
||||
|
||||
getDay(id: string, tripId: string) {
|
||||
return dayService.getDay(id, tripId);
|
||||
}
|
||||
|
||||
create(tripId: string, date?: string, notes?: string) {
|
||||
return dayService.createDay(tripId, date, notes);
|
||||
}
|
||||
|
||||
update(id: string, current: Parameters<typeof dayService.updateDay>[1], fields: { notes?: string; title?: string | null }) {
|
||||
return dayService.updateDay(id, current, fields);
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
dayService.deleteDay(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Controller, Get, HttpException, Param, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
/**
|
||||
* GET /api/trips/:tripId/files/:id/download — authenticated file download.
|
||||
*
|
||||
* Deliberately NOT behind the JwtAuthGuard: it accepts a cookie, a Bearer header
|
||||
* OR a one-shot `?token=` query param (so links can be opened directly), all via
|
||||
* the legacy authenticateDownload helper. Byte-identical to the legacy route:
|
||||
* 401 token, 404 trip/file, 403 path traversal, .pkpass served inline for Wallet.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/files')
|
||||
export class FilesDownloadController {
|
||||
constructor(private readonly files: FilesService) {}
|
||||
|
||||
@Get(':id/download')
|
||||
download(@Req() req: Request, @Res() res: Response, @Param('tripId') tripId: string, @Param('id') id: string): void {
|
||||
const auth = this.files.authenticateDownload(req);
|
||||
if ('error' in auth) {
|
||||
throw new HttpException({ error: auth.error }, auth.status);
|
||||
}
|
||||
|
||||
const trip = this.files.verifyTripAccess(tripId, auth.userId);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
|
||||
const file = this.files.getFileById(id, tripId);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
const { resolved, safe } = this.files.resolveFilePath(file.filename);
|
||||
if (!safe) {
|
||||
throw new HttpException({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new HttpException({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
// Serve Apple Wallet passes inline with the canonical MIME type so Safari
|
||||
// (iOS/macOS) hands them to Wallet instead of downloading as a blob.
|
||||
if (path.extname(resolved).toLowerCase() === '.pkpass') {
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.pkpass');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`);
|
||||
}
|
||||
|
||||
res.sendFile(resolved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { User } from '../../types';
|
||||
import { FilesService } from './files.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { MAX_FILE_SIZE, BLOCKED_EXTENSIONS, filesDir, getAllowedExtensions } from '../../services/fileService';
|
||||
import { isDemoEmail } from '../../services/demo';
|
||||
|
||||
const UPLOAD = {
|
||||
storage: 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)}`),
|
||||
}),
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const reject = () => {
|
||||
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||
err.statusCode = 400;
|
||||
cb(err, false);
|
||||
};
|
||||
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject();
|
||||
const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase());
|
||||
const fileExt = ext.replace('.', '');
|
||||
if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true);
|
||||
reject();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/files — trip file manager (upload, metadata, starring,
|
||||
* trash + restore, reservation links). The authenticated download lives in the
|
||||
* separate unguarded FilesDownloadController (it carries its own token auth).
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/files.ts): trip
|
||||
* access (404), the demo-mode upload block (403), the file_upload/file_edit/
|
||||
* file_delete permissions (403), create 201 / rest 200, the bespoke bodies and
|
||||
* the WebSocket broadcasts with the forwarded X-Socket-Id.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/files')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FilesController {
|
||||
constructor(private readonly files: FilesService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.files.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('trash') trash?: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { files: this.files.listFiles(tripId, trash === 'true') };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
upload(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: { place_id?: string; description?: string; reservation_id?: string },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
|
||||
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
|
||||
}
|
||||
if (!this.files.can('file_upload', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission to upload files' }, 403);
|
||||
}
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
const created = this.files.createFile(tripId, file, user.id, {
|
||||
place_id: body.place_id,
|
||||
description: body.description,
|
||||
reservation_id: body.reservation_id,
|
||||
});
|
||||
this.files.broadcast(tripId, 'file:created', { file: created }, socketId);
|
||||
return { file: created };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: { description?: string; place_id?: string | null; reservation_id?: string | null }, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission to edit files' }, 403);
|
||||
}
|
||||
const file = this.files.getFileById(id, tripId);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'File not found' }, 404);
|
||||
}
|
||||
const updated = this.files.updateFile(id, file, { description: body.description, place_id: body.place_id, reservation_id: body.reservation_id });
|
||||
this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId);
|
||||
return { file: updated };
|
||||
}
|
||||
|
||||
@Patch(':id/star')
|
||||
star(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const file = this.files.getFileById(id, tripId);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'File not found' }, 404);
|
||||
}
|
||||
const updated = this.files.toggleStarred(id, file.starred);
|
||||
this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId);
|
||||
return { file: updated };
|
||||
}
|
||||
|
||||
@Delete('trash/empty')
|
||||
async emptyTrash(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const deleted = await this.files.emptyTrash(tripId);
|
||||
return { success: true, deleted };
|
||||
}
|
||||
|
||||
@Delete(':id/permanent')
|
||||
async permanent(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const file = this.files.getDeletedFile(id, tripId);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'File not found in trash' }, 404);
|
||||
}
|
||||
await this.files.permanentDeleteFile(file);
|
||||
this.files.broadcast(tripId, 'file:deleted', { fileId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission to delete files' }, 403);
|
||||
}
|
||||
const file = this.files.getFileById(id, tripId);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'File not found' }, 404);
|
||||
}
|
||||
this.files.softDeleteFile(id);
|
||||
this.files.broadcast(tripId, 'file:deleted', { fileId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@HttpCode(200) // Express answers restore with res.json (200), not the POST-default 201.
|
||||
restore(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const file = this.files.getDeletedFile(id, tripId);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'File not found in trash' }, 404);
|
||||
}
|
||||
const restored = this.files.restoreFile(id);
|
||||
this.files.broadcast(tripId, 'file:created', { file: restored }, socketId);
|
||||
return { file: restored };
|
||||
}
|
||||
|
||||
@Post(':id/link')
|
||||
@HttpCode(200) // Express answers link with res.json (200).
|
||||
link(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: { reservation_id?: string | null; assignment_id?: string | null; place_id?: string | null }) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const file = this.files.getFileById(id, tripId);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'File not found' }, 404);
|
||||
}
|
||||
const links = this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id });
|
||||
return { success: true, links };
|
||||
}
|
||||
|
||||
@Delete(':id/link/:linkId')
|
||||
unlink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
this.files.deleteFileLink(linkId, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get(':id/links')
|
||||
links(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { links: this.files.getFileLinks(id) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilesController } from './files.controller';
|
||||
import { FilesDownloadController } from './files-download.controller';
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FilesController, FilesDownloadController],
|
||||
providers: [FilesService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User, TripFile } from '../../types';
|
||||
import * as svc from '../../services/fileService';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
type FilePermission = 'file_upload' | 'file_edit' | 'file_delete';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing file service. Trip access, the
|
||||
* file_* permissions, the SQL, the path-resolution guard, the download-token
|
||||
* auth and the WebSocket broadcasts reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
|
||||
can(action: FilePermission, trip: Trip, user: User): boolean {
|
||||
return checkPermission(action, user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
// Download-token auth + safe path resolution (used by the unguarded download route).
|
||||
authenticateDownload(req: Request) { return svc.authenticateDownload(req); }
|
||||
resolveFilePath(filename: string) { return svc.resolveFilePath(filename); }
|
||||
|
||||
listFiles(tripId: string, showTrash: boolean) { return svc.listFiles(tripId, showTrash); }
|
||||
getFileById(id: string, tripId: string) { return svc.getFileById(id, tripId); }
|
||||
getDeletedFile(id: string, tripId: string) { return svc.getDeletedFile(id, tripId); }
|
||||
createFile(tripId: string, file: Parameters<typeof svc.createFile>[1], userId: number, opts: Parameters<typeof svc.createFile>[3]) {
|
||||
return svc.createFile(tripId, file, userId, opts);
|
||||
}
|
||||
updateFile(id: string, current: TripFile, updates: Parameters<typeof svc.updateFile>[2]) { return svc.updateFile(id, current, updates); }
|
||||
toggleStarred(id: string, currentStarred: number | undefined) { return svc.toggleStarred(id, currentStarred); }
|
||||
softDeleteFile(id: string) { return svc.softDeleteFile(id); }
|
||||
restoreFile(id: string) { return svc.restoreFile(id); }
|
||||
permanentDeleteFile(file: TripFile) { return svc.permanentDeleteFile(file); }
|
||||
emptyTrash(tripId: string) { return svc.emptyTrash(tripId); }
|
||||
createFileLink(id: string, opts: Parameters<typeof svc.createFileLink>[1]) { return svc.createFileLink(id, opts); }
|
||||
deleteFileLink(linkId: string, id: string) { return svc.deleteFileLink(linkId, id); }
|
||||
getFileLinks(id: string) { return svc.getFileLinks(id); }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
|
||||
import { JourneyService } from './journey.service';
|
||||
|
||||
/**
|
||||
* Mirrors the legacy `/api/journeys` mount gate: when the Journey addon is
|
||||
* disabled the whole route group answers 404, regardless of auth. Declared
|
||||
* before the JwtAuthGuard so the addon check wins over the 401, exactly as the
|
||||
* Express middleware ordering did.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JourneyAddonGuard implements CanActivate {
|
||||
constructor(private readonly journey: JourneyService) {}
|
||||
|
||||
canActivate(): boolean {
|
||||
if (!this.journey.journeyAddonEnabled()) {
|
||||
throw new HttpException({ error: 'Journey addon is not enabled' }, 404);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Controller, Get, HttpException, Param, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { JourneyService } from './journey.service';
|
||||
|
||||
/**
|
||||
* /api/public/journey — unauthenticated, share-token validated read + photo
|
||||
* proxy for publicly shared journeys.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/journeyPublic.ts):
|
||||
* NOT behind any guard, every route validates the share token (404 on failure),
|
||||
* the unified proxy streams by trek_photo_id and the legacy proxy serves local
|
||||
* files (with the uploads-dir traversal guard) or proxies immich/synology.
|
||||
*/
|
||||
@Controller('api/public/journey')
|
||||
export class JourneyPublicController {
|
||||
constructor(private readonly journey: JourneyService) {}
|
||||
|
||||
@Get(':token')
|
||||
get(@Param('token') token: string) {
|
||||
const data = this.journey.getPublicJourney(token);
|
||||
if (!data) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@Get(':token/photos/:photoId/:kind')
|
||||
async photo(@Param('token') token: string, @Param('photoId') photoId: string, @Param('kind') kind: string, @Res() res: Response): Promise<void> {
|
||||
const valid = this.journey.validateShareTokenForPhoto(token, Number(photoId));
|
||||
if (!valid) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
await this.journey.streamPhoto(res, valid.ownerId, Number(photoId), kind === 'thumbnail' ? 'thumbnail' : 'original');
|
||||
}
|
||||
|
||||
@Get(':token/photo/:provider/:assetId/:ownerId/:kind')
|
||||
async legacyPhoto(
|
||||
@Param('token') token: string,
|
||||
@Param('provider') provider: string,
|
||||
@Param('assetId') assetId: string,
|
||||
@Param('ownerId') ownerId: string,
|
||||
@Param('kind') kind: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const valid = this.journey.validateShareTokenForAsset(token, assetId);
|
||||
if (!valid) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
const wantThumb = kind === 'thumbnail' ? 'thumbnail' : 'original';
|
||||
|
||||
if (provider === 'local') {
|
||||
const resolved = path.resolve(path.join(__dirname, '../../../uploads/journey', assetId));
|
||||
const uploadsDir = path.resolve(__dirname, '../../../uploads');
|
||||
if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveOwnerId = valid.ownerId || Number(ownerId);
|
||||
if (provider === 'immich') {
|
||||
await this.journey.streamImmichAsset(res, effectiveOwnerId, assetId, wantThumb, effectiveOwnerId);
|
||||
} else {
|
||||
try {
|
||||
await this.journey.streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, wantThumb);
|
||||
} catch {
|
||||
res.status(404).json({ error: 'Provider not supported' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import type { User } from '../../types';
|
||||
import { JourneyService } from './journey.service';
|
||||
import { JourneyAddonGuard } from './journey-addon.guard';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { getAllowedExtensions } from '../../services/fileService';
|
||||
|
||||
const uploadsBase = path.join(__dirname, '../../../uploads/journey');
|
||||
const IMAGE_UPLOAD = {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); },
|
||||
filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || '.jpg'}`),
|
||||
}),
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
|
||||
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err, false);
|
||||
}
|
||||
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
|
||||
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;
|
||||
return cb(err, false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* /api/journeys — cross-trip travel narrative (journeys, entries, photo gallery
|
||||
* + provider mirroring, contributors, preferences, share links).
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/journey.ts):
|
||||
* the Journey-addon gate (404) runs before auth, the service owns access
|
||||
* control (null/false → 403/404), create routes answer 201 while cover/trips/
|
||||
* share-link/reorder/patch answer 200 and the two unlink/gallery-delete routes
|
||||
* answer 204. Static prefixes (/suggestions, /available-trips, /entries, /photos)
|
||||
* are declared before /:id so they win over the param.
|
||||
*/
|
||||
@Controller('api/journeys')
|
||||
@UseGuards(JourneyAddonGuard, JwtAuthGuard)
|
||||
export class JourneyController {
|
||||
constructor(private readonly journey: JourneyService) {}
|
||||
|
||||
// ── Static prefix routes (before /:id) ──────────────────────────────────
|
||||
@Get()
|
||||
list(@CurrentUser() user: User) {
|
||||
return { journeys: this.journey.listJourneys(user.id) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: User, @Body() body: { title?: string; subtitle?: string; trip_ids?: unknown[] }) {
|
||||
if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
|
||||
throw new HttpException({ error: 'Title is required' }, 400);
|
||||
}
|
||||
return this.journey.createJourney(user.id, {
|
||||
title: body.title.trim(),
|
||||
subtitle: body.subtitle,
|
||||
trip_ids: Array.isArray(body.trip_ids) ? body.trip_ids.map(Number) : [],
|
||||
});
|
||||
}
|
||||
|
||||
@Get('suggestions')
|
||||
suggestions(@CurrentUser() user: User) {
|
||||
return { trips: this.journey.getSuggestions(user.id) };
|
||||
}
|
||||
|
||||
@Get('available-trips')
|
||||
availableTrips(@CurrentUser() user: User) {
|
||||
return { trips: this.journey.listUserTrips(user.id) };
|
||||
}
|
||||
|
||||
// ── Entries (prefix /entries — before /:id) ─────────────────────────────
|
||||
@Patch('entries/:entryId')
|
||||
updateEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: Record<string, unknown>, @Headers('x-socket-id') socketId?: string) {
|
||||
const result = this.journey.updateEntry(Number(entryId), user.id, body, socketId);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Entry not found' }, 404);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete('entries/:entryId')
|
||||
deleteEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.journey.deleteEntry(Number(entryId), user.id, socketId)) {
|
||||
throw new HttpException({ error: 'Entry not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('entries/:entryId/photos')
|
||||
@UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD))
|
||||
async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: { caption?: string }) {
|
||||
if (!files?.length) {
|
||||
throw new HttpException({ error: 'No files uploaded' }, 400);
|
||||
}
|
||||
const results: unknown[] = [];
|
||||
for (const file of files) {
|
||||
const relativePath = `journey/${file.filename}`;
|
||||
const photo = this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption);
|
||||
if (!photo) continue;
|
||||
// Mirror to Immich only when the user explicitly opted in (#730).
|
||||
if (this.journey.immichAutoUploadEnabled(user.id)) {
|
||||
try {
|
||||
const immichId = await this.journey.uploadToImmich(user.id, relativePath, file.originalname);
|
||||
if (immichId) {
|
||||
this.journey.setPhotoProvider(photo.id, 'immich', immichId, user.id);
|
||||
Object.assign(photo, { provider: 'immich', asset_id: immichId, owner_id: user.id });
|
||||
}
|
||||
} catch {
|
||||
// best-effort mirror; the local photo is already saved
|
||||
}
|
||||
}
|
||||
results.push(photo);
|
||||
}
|
||||
if (!results.length) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return { photos: results };
|
||||
}
|
||||
|
||||
@Post('entries/:entryId/provider-photos')
|
||||
providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; caption?: string; passphrase?: string }) {
|
||||
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
|
||||
if (Array.isArray(body.asset_ids) && body.provider) {
|
||||
const added: unknown[] = [];
|
||||
for (const id of body.asset_ids) {
|
||||
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider, String(id), body.caption, pp);
|
||||
if (photo) added.push(photo);
|
||||
}
|
||||
return { photos: added, added: added.length };
|
||||
}
|
||||
if (!body.provider || !body.asset_id) {
|
||||
throw new HttpException({ error: 'provider and asset_id required' }, 400);
|
||||
}
|
||||
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider, body.asset_id, body.caption, pp);
|
||||
if (!photo) {
|
||||
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
|
||||
}
|
||||
return photo;
|
||||
}
|
||||
|
||||
@Post('entries/:entryId/link-photo')
|
||||
linkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: { journey_photo_id?: unknown; photo_id?: unknown }) {
|
||||
const journeyPhotoId = body.journey_photo_id ?? body.photo_id;
|
||||
if (!journeyPhotoId) {
|
||||
throw new HttpException({ error: 'journey_photo_id required' }, 400);
|
||||
}
|
||||
const result = this.journey.linkPhotoToEntry(Number(entryId), Number(journeyPhotoId), user.id);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete('entries/:entryId/photos/:journeyPhotoId')
|
||||
@HttpCode(204)
|
||||
unlinkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Param('journeyPhotoId') journeyPhotoId: string): void {
|
||||
if (!this.journey.unlinkPhotoFromEntry(Number(entryId), Number(journeyPhotoId), user.id)) {
|
||||
throw new HttpException({ error: 'Not found or not allowed' }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
@Patch('photos/:photoId')
|
||||
updatePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string, @Body() body: Record<string, unknown>) {
|
||||
const result = this.journey.updatePhoto(Number(photoId), user.id, body);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Photo not found' }, 404);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete('photos/:photoId')
|
||||
deletePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string) {
|
||||
const photo = this.journey.deletePhoto(Number(photoId), user.id);
|
||||
if (!photo) {
|
||||
throw new HttpException({ error: 'Photo not found' }, 404);
|
||||
}
|
||||
if (photo.file_path) {
|
||||
try { fs.unlinkSync(path.join(__dirname, '../../../uploads', photo.file_path)); } catch { /* file already gone */ }
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Gallery (prefix /:id/gallery — before /:id) ─────────────────────────
|
||||
@Post(':id/gallery/photos')
|
||||
@UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD))
|
||||
uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) {
|
||||
if (!files?.length) {
|
||||
throw new HttpException({ error: 'No files uploaded' }, 400);
|
||||
}
|
||||
const filePaths = files.map((f) => ({ path: `journey/${f.filename}` }));
|
||||
const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths);
|
||||
if (!photos.length) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return { photos };
|
||||
}
|
||||
|
||||
@Post(':id/gallery/provider-photos')
|
||||
galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; passphrase?: string }) {
|
||||
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
|
||||
if (Array.isArray(body.asset_ids) && body.provider) {
|
||||
const added: unknown[] = [];
|
||||
for (const aid of body.asset_ids) {
|
||||
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider, String(aid), undefined, pp);
|
||||
if (photo) added.push(photo);
|
||||
}
|
||||
return { photos: added, added: added.length };
|
||||
}
|
||||
if (!body.provider || !body.asset_id) {
|
||||
throw new HttpException({ error: 'provider and asset_id required' }, 400);
|
||||
}
|
||||
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider, body.asset_id, undefined, pp);
|
||||
if (!photo) {
|
||||
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
|
||||
}
|
||||
return photo;
|
||||
}
|
||||
|
||||
@Delete(':id/gallery/:journeyPhotoId')
|
||||
@HttpCode(204)
|
||||
deleteGalleryPhoto(@CurrentUser() user: User, @Param('journeyPhotoId') journeyPhotoId: string): void {
|
||||
const photo = this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id);
|
||||
if (!photo) {
|
||||
throw new HttpException({ error: 'Photo not found or not allowed' }, 404);
|
||||
}
|
||||
if (photo.file_path) {
|
||||
try { fs.unlinkSync(path.join(__dirname, '../../../uploads', photo.file_path)); } catch { /* file already gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Journeys /:id ───────────────────────────────────────────────────────
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
const data = this.journey.getJourneyFull(Number(id), user.id);
|
||||
if (!data) {
|
||||
throw new HttpException({ error: 'Journey not found' }, 404);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown>) {
|
||||
const result = this.journey.updateJourney(Number(id), user.id, body);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Journey not found' }, 404);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/cover')
|
||||
@HttpCode(200) // Express answers cover with res.json (200).
|
||||
@UseInterceptors(FileInterceptor('cover', IMAGE_UPLOAD))
|
||||
cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined) {
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
const result = this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` });
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Journey not found' }, 404);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
if (!this.journey.deleteJourney(Number(id), user.id)) {
|
||||
throw new HttpException({ error: 'Journey not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Journey trips ───────────────────────────────────────────────────────
|
||||
@Post(':id/trips')
|
||||
@HttpCode(200) // Express answers with res.json (200).
|
||||
addTrip(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { trip_id?: unknown }) {
|
||||
if (!body.trip_id) {
|
||||
throw new HttpException({ error: 'trip_id required' }, 400);
|
||||
}
|
||||
if (!this.journey.addTripToJourney(Number(id), Number(body.trip_id), user.id)) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':id/trips/:tripId')
|
||||
removeTrip(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
|
||||
if (!this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id)) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Entries under journey ───────────────────────────────────────────────
|
||||
@Get(':id/entries')
|
||||
listEntries(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
const entries = this.journey.listEntries(Number(id), user.id);
|
||||
if (!entries) {
|
||||
throw new HttpException({ error: 'Journey not found' }, 404);
|
||||
}
|
||||
return { entries };
|
||||
}
|
||||
|
||||
@Post(':id/entries')
|
||||
createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown> & { entry_date?: unknown }, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!body.entry_date) {
|
||||
throw new HttpException({ error: 'entry_date is required' }, 400);
|
||||
}
|
||||
const entry = this.journey.createEntry(Number(id), user.id, body, socketId);
|
||||
if (!entry) {
|
||||
throw new HttpException({ error: 'Journey not found' }, 404);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
@Put(':id/entries/reorder')
|
||||
reorderEntries(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { orderedIds?: unknown }, @Headers('x-socket-id') socketId?: string) {
|
||||
const orderedIds = body.orderedIds;
|
||||
if (!Array.isArray(orderedIds) || !orderedIds.every((v) => Number.isFinite(Number(v)))) {
|
||||
throw new HttpException({ error: 'orderedIds must be an array of numbers' }, 400);
|
||||
}
|
||||
if (!this.journey.reorderEntries(Number(id), user.id, orderedIds.map(Number), socketId)) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Contributors ────────────────────────────────────────────────────────
|
||||
@Post(':id/contributors')
|
||||
addContributor(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { user_id?: unknown; role?: 'editor' | 'viewer' }) {
|
||||
if (!body.user_id) {
|
||||
throw new HttpException({ error: 'user_id required' }, 400);
|
||||
}
|
||||
if (!this.journey.addContributor(Number(id), user.id, Number(body.user_id), body.role || 'viewer')) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Patch(':id/contributors/:userId')
|
||||
updateContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: { role?: 'editor' | 'viewer' }) {
|
||||
if (!this.journey.updateContributorRole(Number(id), user.id, Number(userId), body.role as 'editor' | 'viewer')) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':id/contributors/:userId')
|
||||
removeContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) {
|
||||
if (!this.journey.removeContributor(Number(id), user.id, Number(userId))) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── User Preferences ────────────────────────────────────────────────────
|
||||
@Patch(':id/preferences')
|
||||
preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown>) {
|
||||
const result = this.journey.updateJourneyPreferences(Number(id), user.id, body);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Share Link ──────────────────────────────────────────────────────────
|
||||
@Get(':id/share-link')
|
||||
getShareLink(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
return { link: this.journey.getJourneyShareLink(Number(id)) };
|
||||
}
|
||||
|
||||
@Post(':id/share-link')
|
||||
@HttpCode(200) // Express answers with res.json (200).
|
||||
setShareLink(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) {
|
||||
const result = this.journey.createOrUpdateJourneyShareLink(Number(id), user.id, {
|
||||
share_timeline: body.share_timeline,
|
||||
share_gallery: body.share_gallery,
|
||||
share_map: body.share_map,
|
||||
});
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/share-link')
|
||||
deleteShareLink(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
if (!this.journey.deleteJourneyShareLink(Number(id), user.id)) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JourneyController } from './journey.controller';
|
||||
import { JourneyPublicController } from './journey-public.controller';
|
||||
import { JourneyService } from './journey.service';
|
||||
import { JourneyAddonGuard } from './journey-addon.guard';
|
||||
|
||||
@Module({
|
||||
controllers: [JourneyController, JourneyPublicController],
|
||||
providers: [JourneyService, JourneyAddonGuard],
|
||||
})
|
||||
export class JourneyModule {}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import * as svc from '../../services/journeyService';
|
||||
import * as share from '../../services/journeyShareService';
|
||||
import { uploadToImmich, streamImmichAsset } from '../../services/memories/immichService';
|
||||
import { streamPhoto } from '../../services/memories/photoResolverService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import type { Response } from 'express';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing journey services. Access control lives
|
||||
* inside journeyService (each call returns null/false for no-access), so this
|
||||
* just re-exposes the functions plus the share-link helpers, the Immich mirror
|
||||
* and the addon gate the legacy mount enforced.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JourneyService {
|
||||
journeyAddonEnabled(): boolean {
|
||||
return isAddonEnabled(ADDON_IDS.JOURNEY);
|
||||
}
|
||||
|
||||
// Journeys
|
||||
listJourneys(userId: number) { return svc.listJourneys(userId); }
|
||||
createJourney(userId: number, data: Parameters<typeof svc.createJourney>[1]) { return svc.createJourney(userId, data); }
|
||||
getJourneyFull(id: number, userId: number) { return svc.getJourneyFull(id, userId); }
|
||||
updateJourney(id: number, userId: number, data: Parameters<typeof svc.updateJourney>[2]) { return svc.updateJourney(id, userId, data); }
|
||||
deleteJourney(id: number, userId: number) { return svc.deleteJourney(id, userId); }
|
||||
getSuggestions(userId: number) { return svc.getSuggestions(userId); }
|
||||
listUserTrips(userId: number) { return svc.listUserTrips(userId); }
|
||||
updateJourneyPreferences(id: number, userId: number, data: Parameters<typeof svc.updateJourneyPreferences>[2]) { return svc.updateJourneyPreferences(id, userId, data); }
|
||||
|
||||
// Trips
|
||||
addTripToJourney(id: number, tripId: number, userId: number) { return svc.addTripToJourney(id, tripId, userId); }
|
||||
removeTripFromJourney(id: number, tripId: number, userId: number) { return svc.removeTripFromJourney(id, tripId, userId); }
|
||||
|
||||
// Entries
|
||||
listEntries(id: number, userId: number) { return svc.listEntries(id, userId); }
|
||||
// Entry create/update bodies are free-form in the legacy route (req.body: any);
|
||||
// the cast keeps that boundary here so callers needn't pre-shape the payload.
|
||||
createEntry(id: number, userId: number, data: Record<string, unknown>, sid?: string) { return svc.createEntry(id, userId, data as Parameters<typeof svc.createEntry>[2], sid); }
|
||||
updateEntry(entryId: number, userId: number, data: Parameters<typeof svc.updateEntry>[2], sid?: string) { return svc.updateEntry(entryId, userId, data, sid); }
|
||||
deleteEntry(entryId: number, userId: number, sid?: string) { return svc.deleteEntry(entryId, userId, sid); }
|
||||
reorderEntries(id: number, userId: number, orderedIds: number[], sid?: string) { return svc.reorderEntries(id, userId, orderedIds, sid); }
|
||||
|
||||
// Photos
|
||||
addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath: string | undefined, caption: string | undefined) { return svc.addPhoto(entryId, userId, filePath, thumbnailPath, caption); }
|
||||
setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { return svc.setPhotoProvider(photoId, provider, assetId, ownerId); }
|
||||
addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string) { return svc.addProviderPhoto(entryId, userId, provider, assetId, caption, passphrase); }
|
||||
linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number) { return svc.linkPhotoToEntry(entryId, journeyPhotoId, userId); }
|
||||
unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number) { return svc.unlinkPhotoFromEntry(entryId, journeyPhotoId, userId); }
|
||||
updatePhoto(photoId: number, userId: number, data: Parameters<typeof svc.updatePhoto>[2]) { return svc.updatePhoto(photoId, userId, data); }
|
||||
deletePhoto(photoId: number, userId: number) { return svc.deletePhoto(photoId, userId); }
|
||||
uploadGalleryPhotos(id: number, userId: number, filePaths: Parameters<typeof svc.uploadGalleryPhotos>[2]) { return svc.uploadGalleryPhotos(id, userId, filePaths); }
|
||||
addProviderPhotoToGallery(id: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string) { return svc.addProviderPhotoToGallery(id, userId, provider, assetId, caption, passphrase); }
|
||||
deleteGalleryPhoto(journeyPhotoId: number, userId: number) { return svc.deleteGalleryPhoto(journeyPhotoId, userId); }
|
||||
|
||||
// Contributors
|
||||
addContributor(id: number, userId: number, targetUserId: number, role: 'editor' | 'viewer') { return svc.addContributor(id, userId, targetUserId, role); }
|
||||
updateContributorRole(id: number, userId: number, targetUserId: number, role: 'editor' | 'viewer') { return svc.updateContributorRole(id, userId, targetUserId, role); }
|
||||
removeContributor(id: number, userId: number, targetUserId: number) { return svc.removeContributor(id, userId, targetUserId); }
|
||||
|
||||
// Share links
|
||||
getJourneyShareLink(id: number) { return share.getJourneyShareLink(id); }
|
||||
createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters<typeof share.createOrUpdateJourneyShareLink>[2]) { return share.createOrUpdateJourneyShareLink(id, userId, data); }
|
||||
deleteJourneyShareLink(id: number, userId: number) { return share.deleteJourneyShareLink(id, userId); }
|
||||
|
||||
// Immich mirror (only when the user opted in via integration settings)
|
||||
immichAutoUploadEnabled(userId: number): boolean {
|
||||
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
|
||||
return !!prefs?.immich_auto_upload;
|
||||
}
|
||||
uploadToImmich(userId: number, relativePath: string, originalName: string) { return uploadToImmich(userId, relativePath, originalName); }
|
||||
|
||||
// Public (share-token) access — no auth, validated by token.
|
||||
getPublicJourney(token: string) { return share.getPublicJourney(token); }
|
||||
validateShareTokenForPhoto(token: string, photoId: number) { return share.validateShareTokenForPhoto(token, photoId); }
|
||||
validateShareTokenForAsset(token: string, assetId: string) { return share.validateShareTokenForAsset(token, assetId); }
|
||||
streamPhoto(res: Response, ownerId: number, photoId: number, kind: 'thumbnail' | 'original') { return streamPhoto(res, ownerId, photoId, kind); }
|
||||
streamImmichAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) { return streamImmichAsset(res, userId, assetId, kind, ownerId); }
|
||||
async streamSynologyAsset(res: Response, userId: number, ownerId: number, assetId: string, kind: 'thumbnail' | 'original') {
|
||||
const { streamSynologyAsset } = await import('../../services/memories/synologyService');
|
||||
return streamSynologyAsset(res, userId, ownerId, assetId, kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import type {
|
||||
MapsAutocompleteResult,
|
||||
MapsPlaceDetailsResult,
|
||||
MapsPlacePhotoResult,
|
||||
MapsResolveUrlResult,
|
||||
MapsReverseResult,
|
||||
MapsSearchResult,
|
||||
} from '@trek/shared';
|
||||
import type { User } from '../../types';
|
||||
import { MapsService } from './maps.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
|
||||
|
||||
/** Maps a thrown service error to the same status + { error } body Express sent. */
|
||||
function toHttpException(err: unknown, fallbackMessage: string, defaultStatus: number): HttpException {
|
||||
const status = (err as { status?: number }).status || defaultStatus;
|
||||
const message = err instanceof Error ? err.message : fallbackMessage;
|
||||
return new HttpException({ error: message }, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/maps — place search, autocomplete, details, photos, reverse geocoding and
|
||||
* Google-Maps-URL resolution.
|
||||
*
|
||||
* Behaviour is byte-identical to the legacy Express route (server/src/routes/
|
||||
* maps.ts): same auth, same bespoke 400 validation messages, the same
|
||||
* per-endpoint kill-switch short-circuits, the same error status/body mapping,
|
||||
* and the same diagnostic logging. The SSRF guard lives in the underlying
|
||||
* service and is reused unchanged.
|
||||
*/
|
||||
@Controller('api/maps')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MapsController {
|
||||
constructor(private readonly maps: MapsService) {}
|
||||
|
||||
@Post('search')
|
||||
@HttpCode(200) // Express answers with res.json (200); Nest would otherwise default POST to 201.
|
||||
async search(
|
||||
@CurrentUser() user: User,
|
||||
@Body('query') query: unknown,
|
||||
@Query('lang') lang?: string,
|
||||
): Promise<MapsSearchResult> {
|
||||
if (!query) {
|
||||
throw new HttpException({ error: 'Search query is required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.maps.search(user.id, query as string, lang);
|
||||
} catch (err: unknown) {
|
||||
console.error('Maps search error:', err);
|
||||
throw toHttpException(err, 'Search error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('autocomplete')
|
||||
@HttpCode(200)
|
||||
async autocomplete(
|
||||
@CurrentUser() user: User,
|
||||
@Body('input') input: unknown,
|
||||
@Body('lang') lang?: string,
|
||||
@Body('locationBias') locationBias?: LocationBias,
|
||||
): Promise<MapsAutocompleteResult | { suggestions: never[]; source: string }> {
|
||||
if (this.maps.autocompleteDisabled()) {
|
||||
return { suggestions: [], source: 'disabled' };
|
||||
}
|
||||
if (!input || typeof input !== 'string') {
|
||||
throw new HttpException({ error: 'Input is required' }, 400);
|
||||
}
|
||||
if (input.length > 200) {
|
||||
throw new HttpException({ error: 'Input too long (max 200 chars)' }, 400);
|
||||
}
|
||||
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)) {
|
||||
throw new HttpException({ error: 'Invalid locationBias: low and high must have finite lat and lng' }, 400);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await this.maps.autocomplete(user.id, input, lang, locationBias);
|
||||
} catch (err: unknown) {
|
||||
console.error('Maps autocomplete error:', err);
|
||||
throw toHttpException(err, 'Autocomplete error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('details/:placeId')
|
||||
async details(
|
||||
@CurrentUser() user: User,
|
||||
@Param('placeId') placeId: string,
|
||||
@Query('expand') expand?: string,
|
||||
@Query('lang') lang?: string,
|
||||
@Query('refresh') refresh?: string,
|
||||
): Promise<MapsPlaceDetailsResult> {
|
||||
if (this.maps.detailsDisabled()) {
|
||||
return { place: null, disabled: true };
|
||||
}
|
||||
try {
|
||||
return expand
|
||||
? await this.maps.detailsExpanded(user.id, placeId, lang, refresh === '1')
|
||||
: await this.maps.details(user.id, placeId, lang);
|
||||
} catch (err: unknown) {
|
||||
console.error('Maps details error:', err);
|
||||
throw toHttpException(err, 'Error fetching place details', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('place-photo/:placeId')
|
||||
async placePhoto(
|
||||
@CurrentUser() user: User,
|
||||
@Param('placeId') placeId: string,
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
@Query('name') name?: string,
|
||||
): Promise<MapsPlacePhotoResult | { photoUrl: null }> {
|
||||
// Kill-switch only applies to Google Places fetches — Wikimedia (coords:) stays allowed.
|
||||
if (!placeId.startsWith('coords:') && this.maps.photosDisabled()) {
|
||||
return { photoUrl: null };
|
||||
}
|
||||
try {
|
||||
return await this.maps.photo(user.id, placeId, parseFloat(lat as string), parseFloat(lng as string), name);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
if (status >= 500) console.error('Place photo error:', err);
|
||||
throw toHttpException(err, 'Error fetching photo', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('place-photo/:placeId/bytes')
|
||||
placePhotoBytes(@Param('placeId') placeId: string, @Res() res: Response): void {
|
||||
const fp = this.maps.photoBytesPath(placeId);
|
||||
if (!fp) {
|
||||
res.status(404).json({ error: 'Photo not cached' });
|
||||
return;
|
||||
}
|
||||
// Stream the cached file directly instead of res.sendFile(): the send library
|
||||
// bundled under @nestjs/platform-express rejects absolute Windows paths (drive
|
||||
// letter, no `root`) with a NotFoundError that surfaced as an unhandled 500,
|
||||
// even though the file exists. A plain read stream serves the bytes
|
||||
// cross-platform; a read error still yields the legacy 404. Cached photos are
|
||||
// always JPEG (placePhotoCache writes `<hash>.jpg`).
|
||||
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
res.type('image/jpeg');
|
||||
const stream = createReadStream(fp);
|
||||
stream.on('error', () => {
|
||||
if (!res.headersSent) res.status(404).json({ error: 'Photo not cached' });
|
||||
});
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Get('reverse')
|
||||
async reverse(
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
@Query('lang') lang?: string,
|
||||
): Promise<MapsReverseResult> {
|
||||
if (!lat || !lng) {
|
||||
throw new HttpException({ error: 'lat and lng required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.maps.reverse(lat, lng, lang);
|
||||
} catch {
|
||||
// The legacy route swallows reverse-geocode failures into an empty result.
|
||||
return { name: null, address: null };
|
||||
}
|
||||
}
|
||||
|
||||
@Post('resolve-url')
|
||||
@HttpCode(200)
|
||||
async resolveUrl(@Body('url') url: unknown): Promise<MapsResolveUrlResult> {
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new HttpException({ error: 'URL is required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.maps.resolveUrl(url);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to resolve URL';
|
||||
console.error('[Maps] URL resolve error:', message);
|
||||
throw toHttpException(err, 'Failed to resolve URL', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MapsController } from './maps.controller';
|
||||
import { MapsService } from './maps.service';
|
||||
|
||||
/** Maps / geo domain (L3 leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [MapsController],
|
||||
providers: [MapsService],
|
||||
})
|
||||
export class MapsModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
MapsSearchResult,
|
||||
MapsAutocompleteResult,
|
||||
MapsPlaceDetailsResult,
|
||||
MapsPlacePhotoResult,
|
||||
MapsReverseResult,
|
||||
MapsResolveUrlResult,
|
||||
} from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import {
|
||||
searchPlaces,
|
||||
autocompletePlaces,
|
||||
getPlaceDetails,
|
||||
getPlaceDetailsExpanded,
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
} from '../../services/mapsService';
|
||||
import { serveFilePath } from '../../services/placePhotoCache';
|
||||
|
||||
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing maps service. All geocoding, the
|
||||
* provider fan-out (Nominatim/Overpass/Google) and — importantly — the SSRF
|
||||
* guard live in mapsService and are reused unchanged, so behaviour and the
|
||||
* outbound-URL protection are identical.
|
||||
*
|
||||
* The per-endpoint kill-switches are settings reads the legacy route does
|
||||
* inline; they're encapsulated here as `*Disabled()` helpers over the same
|
||||
* `app_settings` rows.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MapsService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
private isSettingDisabled(key: string): boolean {
|
||||
const row = this.database.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?',
|
||||
key,
|
||||
);
|
||||
return row?.value === 'false';
|
||||
}
|
||||
|
||||
autocompleteDisabled(): boolean {
|
||||
return this.isSettingDisabled('places_autocomplete_enabled');
|
||||
}
|
||||
|
||||
detailsDisabled(): boolean {
|
||||
return this.isSettingDisabled('places_details_enabled');
|
||||
}
|
||||
|
||||
photosDisabled(): boolean {
|
||||
return this.isSettingDisabled('places_photos_enabled');
|
||||
}
|
||||
|
||||
search(userId: number, query: string, lang?: string): Promise<MapsSearchResult> {
|
||||
return searchPlaces(userId, query, lang) as Promise<MapsSearchResult>;
|
||||
}
|
||||
|
||||
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise<MapsAutocompleteResult> {
|
||||
return autocompletePlaces(userId, input, lang, locationBias) as Promise<MapsAutocompleteResult>;
|
||||
}
|
||||
|
||||
details(userId: number, placeId: string, lang?: string): Promise<MapsPlaceDetailsResult> {
|
||||
return getPlaceDetails(userId, placeId, lang) as Promise<MapsPlaceDetailsResult>;
|
||||
}
|
||||
|
||||
detailsExpanded(userId: number, placeId: string, lang: string | undefined, refresh: boolean): Promise<MapsPlaceDetailsResult> {
|
||||
return getPlaceDetailsExpanded(userId, placeId, lang, refresh) as Promise<MapsPlaceDetailsResult>;
|
||||
}
|
||||
|
||||
photo(userId: number, placeId: string, lat: number, lng: number, name?: string): Promise<MapsPlacePhotoResult> {
|
||||
return getPlacePhoto(userId, placeId, lat, lng, name) as Promise<MapsPlacePhotoResult>;
|
||||
}
|
||||
|
||||
photoBytesPath(placeId: string): string | null {
|
||||
return serveFilePath(placeId);
|
||||
}
|
||||
|
||||
reverse(lat: string, lng: string, lang?: string): Promise<MapsReverseResult> {
|
||||
return reverseGeocode(lat, lng, lang) as Promise<MapsReverseResult>;
|
||||
}
|
||||
|
||||
resolveUrl(url: string): Promise<MapsResolveUrlResult> {
|
||||
return resolveGoogleMapsUrl(url) as Promise<MapsResolveUrlResult>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { ChannelTestResult, UnreadCountResult } from '@trek/shared';
|
||||
import type { User } from '../../types';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
// The masked placeholder the client sends instead of a stored secret (8× U+2022).
|
||||
const MASKED = '••••••••';
|
||||
|
||||
/**
|
||||
* /api/notifications — channel-preference matrix, channel test pings, and in-app
|
||||
* notifications.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/notifications.ts):
|
||||
* same auth, the same inline admin gate on /test-smtp (note: it returns
|
||||
* { error: 'Admin only' }, NOT the AdminGuard's wording), the same webhook/ntfy
|
||||
* fallback resolution, the same id parsing + 400/404 bodies, and the same status
|
||||
* codes. POSTs that answer with res.json stay 200 (Nest would default to 201).
|
||||
* The static /in-app/read-all and /in-app/all routes are declared before the
|
||||
* /in-app/:id routes so they win over the param, matching the legacy order.
|
||||
*/
|
||||
@Controller('api/notifications')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotificationsController {
|
||||
constructor(private readonly notifications: NotificationsService) {}
|
||||
|
||||
@Get('preferences')
|
||||
getPreferences(@CurrentUser() user: User) {
|
||||
return this.notifications.getPreferences(user.id, user.role);
|
||||
}
|
||||
|
||||
@Put('preferences')
|
||||
setPreferences(@CurrentUser() user: User, @Body() body: Record<string, Record<string, boolean>>) {
|
||||
this.notifications.setPreferences(user.id, body);
|
||||
return this.notifications.getPreferences(user.id, user.role);
|
||||
}
|
||||
|
||||
@Post('test-smtp')
|
||||
@HttpCode(200)
|
||||
async testSmtp(@CurrentUser() user: User, @Body('email') email?: string): Promise<ChannelTestResult> {
|
||||
if (user.role !== 'admin') {
|
||||
throw new HttpException({ error: 'Admin only' }, 403);
|
||||
}
|
||||
return this.notifications.testSmtp(email || user.email);
|
||||
}
|
||||
|
||||
@Post('test-webhook')
|
||||
@HttpCode(200)
|
||||
async testWebhook(@CurrentUser() user: User, @Body('url') urlInput?: unknown): Promise<ChannelTestResult> {
|
||||
let url = urlInput;
|
||||
if (!url || url === MASKED) {
|
||||
url = this.notifications.userWebhookUrl(user.id);
|
||||
if (!url && user.role === 'admin') url = this.notifications.adminWebhookUrl();
|
||||
if (!url) {
|
||||
throw new HttpException({ error: 'No webhook URL configured' }, 400);
|
||||
}
|
||||
}
|
||||
if (typeof url !== 'string') {
|
||||
throw new HttpException({ error: 'url must be a string' }, 400);
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
throw new HttpException({ error: 'Invalid URL' }, 400);
|
||||
}
|
||||
return this.notifications.testWebhook(url);
|
||||
}
|
||||
|
||||
@Post('test-ntfy')
|
||||
@HttpCode(200)
|
||||
async testNtfy(
|
||||
@CurrentUser() user: User,
|
||||
@Body('topic') topic?: string,
|
||||
@Body('server') server?: string,
|
||||
@Body('token') token?: string,
|
||||
): Promise<ChannelTestResult> {
|
||||
const userCfg = this.notifications.userNtfyConfig(user.id);
|
||||
const adminCfg = this.notifications.adminNtfyConfig();
|
||||
|
||||
const resolvedTopic = topic || userCfg?.topic || undefined;
|
||||
const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
|
||||
// Reuse the saved token when the request sends null, empty, or the masked placeholder.
|
||||
const resolvedToken = (token && token !== MASKED)
|
||||
? token
|
||||
: (userCfg?.token ?? adminCfg.token ?? null);
|
||||
|
||||
if (!resolvedTopic) {
|
||||
throw new HttpException({ error: 'No ntfy topic configured' }, 400);
|
||||
}
|
||||
return this.notifications.testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken });
|
||||
}
|
||||
|
||||
@Get('in-app')
|
||||
listInApp(
|
||||
@CurrentUser() user: User,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
@Query('unread_only') unreadOnly?: string,
|
||||
) {
|
||||
return this.notifications.listInApp(user.id, {
|
||||
limit: Math.min(parseInt(limit as string) || 20, 50),
|
||||
offset: parseInt(offset as string) || 0,
|
||||
unreadOnly: unreadOnly === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
@Get('in-app/unread-count')
|
||||
unreadCount(@CurrentUser() user: User): UnreadCountResult {
|
||||
return { count: this.notifications.unreadCount(user.id) };
|
||||
}
|
||||
|
||||
@Put('in-app/read-all')
|
||||
readAll(@CurrentUser() user: User): { success: boolean; count: number } {
|
||||
return { success: true, count: this.notifications.markAllRead(user.id) };
|
||||
}
|
||||
|
||||
@Delete('in-app/all')
|
||||
deleteAll(@CurrentUser() user: User): { success: boolean; count: number } {
|
||||
return { success: true, count: this.notifications.deleteAll(user.id) };
|
||||
}
|
||||
|
||||
@Put('in-app/:id/read')
|
||||
markRead(@CurrentUser() user: User, @Param('id') idParam: string): { success: boolean } {
|
||||
const id = this.parseId(idParam);
|
||||
if (!this.notifications.markRead(id, user.id)) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put('in-app/:id/unread')
|
||||
markUnread(@CurrentUser() user: User, @Param('id') idParam: string): { success: boolean } {
|
||||
const id = this.parseId(idParam);
|
||||
if (!this.notifications.markUnread(id, user.id)) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('in-app/:id')
|
||||
deleteOne(@CurrentUser() user: User, @Param('id') idParam: string): { success: boolean } {
|
||||
const id = this.parseId(idParam);
|
||||
if (!this.notifications.deleteOne(id, user.id)) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('in-app/:id/respond')
|
||||
@HttpCode(200)
|
||||
async respond(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') idParam: string,
|
||||
@Body('response') response?: unknown,
|
||||
): Promise<{ success: boolean; notification: unknown }> {
|
||||
const id = this.parseId(idParam);
|
||||
if (response !== 'positive' && response !== 'negative') {
|
||||
throw new HttpException({ error: 'response must be "positive" or "negative"' }, 400);
|
||||
}
|
||||
const result = await this.notifications.respond(id, user.id, response);
|
||||
if (!result.success) {
|
||||
throw new HttpException({ error: result.error }, 400);
|
||||
}
|
||||
return { success: true, notification: result.notification };
|
||||
}
|
||||
|
||||
/** parseInt + the legacy "Invalid id" 400 guard, shared by the /:id handlers. */
|
||||
private parseId(idParam: string): number {
|
||||
const id = parseInt(idParam);
|
||||
if (isNaN(id)) {
|
||||
throw new HttpException({ error: 'Invalid id' }, 400);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
/** Notifications domain (L6 leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ChannelTestResult } from '@trek/shared';
|
||||
import {
|
||||
testSmtp,
|
||||
testWebhook,
|
||||
testNtfy,
|
||||
getAdminWebhookUrl,
|
||||
getUserWebhookUrl,
|
||||
getUserNtfyConfig,
|
||||
getAdminNtfyConfig,
|
||||
} from '../../services/notifications';
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markRead,
|
||||
markUnread,
|
||||
markAllRead,
|
||||
deleteNotification,
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
} from '../../services/inAppNotifications';
|
||||
import { getPreferencesMatrix, setPreferences } from '../../services/notificationPreferencesService';
|
||||
|
||||
type NtfyConfig = ReturnType<typeof getAdminNtfyConfig>;
|
||||
type RespondResult = Awaited<ReturnType<typeof respondToBoolean>>;
|
||||
type PreferencesMatrix = ReturnType<typeof getPreferencesMatrix>;
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing notification services. Channel delivery
|
||||
* (including the WebSocket push in inAppNotifications) and the preference
|
||||
* persistence all stay in the upstream services, so behaviour — including
|
||||
* real-time delivery — is unchanged. The webhook/ntfy fallback resolution that
|
||||
* the legacy route does inline is exposed here as small accessors so the
|
||||
* controller can reproduce it exactly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
getPreferences(userId: number, role: string): PreferencesMatrix {
|
||||
return getPreferencesMatrix(userId, role, 'user');
|
||||
}
|
||||
|
||||
setPreferences(userId: number, body: Parameters<typeof setPreferences>[1]): void {
|
||||
setPreferences(userId, body);
|
||||
}
|
||||
|
||||
testSmtp(to: string): Promise<ChannelTestResult> {
|
||||
return testSmtp(to);
|
||||
}
|
||||
|
||||
testWebhook(url: string): Promise<ChannelTestResult> {
|
||||
return testWebhook(url);
|
||||
}
|
||||
|
||||
testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise<ChannelTestResult> {
|
||||
return testNtfy(cfg);
|
||||
}
|
||||
|
||||
userWebhookUrl(userId: number): string | null {
|
||||
return getUserWebhookUrl(userId);
|
||||
}
|
||||
|
||||
adminWebhookUrl(): string | null {
|
||||
return getAdminWebhookUrl();
|
||||
}
|
||||
|
||||
userNtfyConfig(userId: number): NtfyConfig | null {
|
||||
return getUserNtfyConfig(userId);
|
||||
}
|
||||
|
||||
adminNtfyConfig(): NtfyConfig {
|
||||
return getAdminNtfyConfig();
|
||||
}
|
||||
|
||||
// Returns the native service shape (NotificationRow[] is a superset of the
|
||||
// client-facing InAppListResult contract); the controller surfaces it as-is.
|
||||
listInApp(userId: number, options: { limit?: number; offset?: number; unreadOnly?: boolean }) {
|
||||
return getNotifications(userId, options);
|
||||
}
|
||||
|
||||
unreadCount(userId: number): number {
|
||||
return getUnreadCount(userId);
|
||||
}
|
||||
|
||||
markRead(id: number, userId: number): boolean {
|
||||
return markRead(id, userId);
|
||||
}
|
||||
|
||||
markUnread(id: number, userId: number): boolean {
|
||||
return markUnread(id, userId);
|
||||
}
|
||||
|
||||
markAllRead(userId: number): number {
|
||||
return markAllRead(userId);
|
||||
}
|
||||
|
||||
deleteOne(id: number, userId: number): boolean {
|
||||
return deleteNotification(id, userId);
|
||||
}
|
||||
|
||||
deleteAll(userId: number): number {
|
||||
return deleteAll(userId);
|
||||
}
|
||||
|
||||
respond(id: number, userId: number, response: 'positive' | 'negative'): Promise<RespondResult> {
|
||||
return respondToBoolean(id, userId, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { OauthService } from './oauth.service';
|
||||
import { RateLimitService } from '../auth/rate-limit.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CookieAuthGuard } from '../auth/cookie-auth.guard';
|
||||
import { OptionalJwtGuard } from '../auth/optional-jwt.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { getClientIp } from '../../services/auditLog';
|
||||
import type { User } from '../../types';
|
||||
import type { AuthorizeParams } from '../../services/oauthService';
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
/**
|
||||
* Authenticated OAuth management endpoints (the SPA's consent + client/session
|
||||
* UI) — byte-identical to the legacy oauthApiRouter (server/src/routes/oauth.ts):
|
||||
* MCP-addon gated (404 on the anonymous validate to avoid fingerprinting, 403
|
||||
* elsewhere), optional-auth on validate, cookie-only auth on state-changing
|
||||
* routes (consent/create/rotate/delete/revoke) and Bearer-or-cookie auth on the
|
||||
* read lists. create answers 201; the rest 200.
|
||||
*/
|
||||
@Controller('api/oauth')
|
||||
export class OauthApiController {
|
||||
constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService) {}
|
||||
|
||||
private requireMcp403(): void {
|
||||
if (!this.oauth.mcpEnabled()) {
|
||||
throw new HttpException({ error: 'MCP is not enabled' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('authorize/validate')
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
validate(@Req() req: Request, @Query() params: Partial<AuthorizeParams>, @Res({ passthrough: true }) res: Response) {
|
||||
if (!this.rl.check('oauth_validate', req.ip || 'unknown', 30, MIN, Date.now())) {
|
||||
throw new HttpException({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' }, 429);
|
||||
}
|
||||
if (!this.oauth.mcpEnabled()) {
|
||||
// 404 (not 403) with an empty body so anonymous callers can't fingerprint the feature.
|
||||
res.status(404).end();
|
||||
return undefined;
|
||||
}
|
||||
const userId = (req.user as User | undefined)?.id ?? null;
|
||||
const result = this.oauth.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,
|
||||
);
|
||||
if (userId === null && result.valid) {
|
||||
return { valid: result.valid, loginRequired: true };
|
||||
}
|
||||
if (userId === null && !result.valid) {
|
||||
return { valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('authorize')
|
||||
@HttpCode(200) // Express answers consent with res.json (200), not the POST-default 201.
|
||||
@UseGuards(CookieAuthGuard)
|
||||
authorize(@CurrentUser() user: User, @Body() body: {
|
||||
client_id: string; redirect_uri: string; scope: string; state?: string;
|
||||
code_challenge: string; code_challenge_method: string; approved: boolean; resource?: string;
|
||||
}, @Req() req: Request) {
|
||||
const ip = getClientIp(req);
|
||||
if (!this.oauth.mcpEnabled()) {
|
||||
throw new HttpException({ error: 'MCP is not enabled' }, 403);
|
||||
}
|
||||
if (!body.approved) {
|
||||
const url = new URL(body.redirect_uri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
url.searchParams.set('error_description', 'User denied the authorization request');
|
||||
if (body.state) url.searchParams.set('state', body.state);
|
||||
return { redirect: url.toString() };
|
||||
}
|
||||
const params: AuthorizeParams = {
|
||||
response_type: 'code',
|
||||
client_id: body.client_id,
|
||||
redirect_uri: body.redirect_uri,
|
||||
scope: body.scope,
|
||||
state: body.state,
|
||||
code_challenge: body.code_challenge,
|
||||
code_challenge_method: body.code_challenge_method,
|
||||
resource: body.resource,
|
||||
};
|
||||
const validation = this.oauth.validateAuthorizeRequest(params, user.id);
|
||||
if (!validation.valid) {
|
||||
throw new HttpException({ error: validation.error, error_description: validation.error_description }, 400);
|
||||
}
|
||||
const scopes = validation.scopes!;
|
||||
this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
|
||||
const code = this.oauth.createAuthCode({
|
||||
clientId: body.client_id,
|
||||
userId: user.id,
|
||||
redirectUri: body.redirect_uri,
|
||||
scopes,
|
||||
resource: validation.resource ?? null,
|
||||
codeChallenge: body.code_challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
if (!code) {
|
||||
throw new HttpException({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' }, 503);
|
||||
}
|
||||
const url = new URL(body.redirect_uri);
|
||||
url.searchParams.set('code', code);
|
||||
if (body.state) url.searchParams.set('state', body.state);
|
||||
return { redirect: url.toString() };
|
||||
}
|
||||
|
||||
@Get('clients')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listClients(@CurrentUser() user: User) {
|
||||
this.requireMcp403();
|
||||
return { clients: this.oauth.listOAuthClients(user.id) };
|
||||
}
|
||||
|
||||
@Post('clients')
|
||||
@HttpCode(201)
|
||||
@UseGuards(CookieAuthGuard)
|
||||
createClient(@CurrentUser() user: User, @Body() body: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }, @Req() req: Request) {
|
||||
this.requireMcp403();
|
||||
const result = this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials });
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('clients/:id/rotate')
|
||||
@HttpCode(200)
|
||||
@UseGuards(CookieAuthGuard)
|
||||
rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
this.requireMcp403();
|
||||
const result = this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
return { client_secret: result.client_secret };
|
||||
}
|
||||
|
||||
@Delete('clients/:id')
|
||||
@UseGuards(CookieAuthGuard)
|
||||
deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
this.requireMcp403();
|
||||
const result = this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('sessions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listSessions(@CurrentUser() user: User) {
|
||||
this.requireMcp403();
|
||||
return { sessions: this.oauth.listOAuthSessions(user.id) };
|
||||
}
|
||||
|
||||
@Delete('sessions/:id')
|
||||
@UseGuards(CookieAuthGuard)
|
||||
revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
this.requireMcp403();
|
||||
const result = this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Controller, Get, Headers, HttpCode, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { OauthService } from './oauth.service';
|
||||
import { RateLimitService } from '../auth/rate-limit.service';
|
||||
import { writeAudit, getClientIp, logWarn } from '../../services/auditLog';
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
/**
|
||||
* Public OAuth 2.1 endpoints (no session) — byte-identical to the legacy
|
||||
* oauthPublicRouter (server/src/routes/oauth.ts): MCP-addon gated (404), the
|
||||
* per-(ip,client) token / per-ip revoke rate limits, no-store cache headers on
|
||||
* /token, the WWW-Authenticate challenge on /userinfo, the three grant types
|
||||
* and the RFC 7009 always-200 revoke. Uses @Res directly because every branch
|
||||
* sets headers + a specific status the way the spec requires.
|
||||
*/
|
||||
@Controller('oauth')
|
||||
export class OauthPublicController {
|
||||
constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService) {}
|
||||
|
||||
@Post('token')
|
||||
@HttpCode(200) // token success uses res.json without an explicit status; Express defaults to 200 (Nest POST would default to 201).
|
||||
token(@Req() req: Request, @Res() res: Response): void {
|
||||
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
|
||||
|
||||
const body: Record<string, string> = typeof req.body === 'object' && req.body ? req.body : {};
|
||||
if (!this.rl.check('oauth_token', `${req.ip}|${body.client_id ?? ''}`, 30, MIN, Date.now())) {
|
||||
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Pragma', 'no-cache');
|
||||
|
||||
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!client_id) {
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (grant_type === 'authorization_code') {
|
||||
if (!code || !redirect_uri || !code_verifier) {
|
||||
res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
|
||||
return;
|
||||
}
|
||||
const pending = this.oauth.consumeAuthCode(code);
|
||||
const invalidGrant = (reason: string, userId: number | null) => {
|
||||
writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip });
|
||||
res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
};
|
||||
if (!pending) return invalidGrant('code_invalid_or_expired', null);
|
||||
if (pending.clientId !== client_id) return invalidGrant('client_id_mismatch', pending.userId);
|
||||
if (pending.redirectUri !== redirect_uri) return invalidGrant('redirect_uri_mismatch', pending.userId);
|
||||
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) return invalidGrant('resource_mismatch', pending.userId);
|
||||
if (!this.oauth.authenticateClient(client_id, client_secret)) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
return;
|
||||
}
|
||||
if (!this.oauth.verifyPKCE(code_verifier, pending.codeChallenge)) return invalidGrant('pkce_failed', pending.userId);
|
||||
const tokens = this.oauth.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 });
|
||||
res.json(tokens);
|
||||
return;
|
||||
}
|
||||
|
||||
if (grant_type === 'refresh_token') {
|
||||
if (!refresh_token) {
|
||||
res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
|
||||
return;
|
||||
}
|
||||
const result = this.oauth.refreshTokens(refresh_token, client_id, client_secret, ip);
|
||||
if (result.error) {
|
||||
if (result.error === 'invalid_client') logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
res.status(result.status || 400).json({ error: result.error, error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired' });
|
||||
return;
|
||||
}
|
||||
res.json(result.tokens);
|
||||
return;
|
||||
}
|
||||
|
||||
if (grant_type === 'client_credentials') {
|
||||
if (!client_secret) {
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
|
||||
return;
|
||||
}
|
||||
const client = this.oauth.authenticateClient(client_id, client_secret);
|
||||
if (!client) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
return;
|
||||
}
|
||||
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 });
|
||||
res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
|
||||
return;
|
||||
}
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
let grantedScopes: string[];
|
||||
if (body.scope) {
|
||||
const requested = body.scope.split(' ').filter(Boolean);
|
||||
const invalid = requested.filter((s) => !allowedScopes.includes(s));
|
||||
if (invalid.length > 0) {
|
||||
res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
grantedScopes = requested;
|
||||
} else {
|
||||
grantedScopes = allowedScopes;
|
||||
}
|
||||
const audience = resource ? resource.replace(/\/+$/, '') : `${this.oauth.mcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
const tokens = this.oauth.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 });
|
||||
res.json(tokens);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
|
||||
}
|
||||
|
||||
@Get('userinfo')
|
||||
userinfo(@Headers('authorization') auth: string | undefined, @Res() res: Response): void {
|
||||
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
|
||||
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
|
||||
res.status(401).json({ error: 'invalid_token' });
|
||||
return;
|
||||
}
|
||||
const info = this.oauth.getUserByAccessToken(auth.slice(7));
|
||||
if (!info) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
|
||||
res.status(401).json({ error: 'invalid_token' });
|
||||
return;
|
||||
}
|
||||
res.json({ sub: String(info.user.id), email: info.user.email, email_verified: true, preferred_username: info.user.username });
|
||||
}
|
||||
|
||||
@Post('revoke')
|
||||
revoke(@Req() req: Request, @Res() res: Response): void {
|
||||
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
|
||||
if (!this.rl.check('oauth_revoke', req.ip || 'unknown', 10, MIN, Date.now())) {
|
||||
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
|
||||
return;
|
||||
}
|
||||
const body: Record<string, string> = typeof req.body === 'object' && req.body ? req.body : {};
|
||||
const { token, client_id, client_secret } = body;
|
||||
const ip = getClientIp(req);
|
||||
if (!token || !client_id) {
|
||||
res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
|
||||
return;
|
||||
}
|
||||
if (!this.oauth.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 });
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
return;
|
||||
}
|
||||
this.oauth.revokeToken(token, client_id, undefined, ip);
|
||||
res.status(200).json({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OauthPublicController } from './oauth-public.controller';
|
||||
import { OauthApiController } from './oauth-api.controller';
|
||||
import { OauthService } from './oauth.service';
|
||||
import { RateLimitService } from '../auth/rate-limit.service';
|
||||
|
||||
/**
|
||||
* OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's
|
||||
* authenticated consent/client/session management. The SDK-mounted
|
||||
* /oauth/authorize, /oauth/register and /oauth/consent stay on Express, so the
|
||||
* strangler lists /oauth/token, /oauth/userinfo, /oauth/revoke explicitly.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [OauthPublicController, OauthApiController],
|
||||
providers: [OauthService, RateLimitService],
|
||||
})
|
||||
export class OauthModule {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as oauth from '../../services/oauthService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { getMcpSafeUrl } from '../../services/notifications';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing OAuth 2.1 service. The grant handling,
|
||||
* PKCE, client auth, consent storage, token issue/refresh/revoke and the
|
||||
* client/session CRUD all reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OauthService {
|
||||
mcpEnabled(): boolean { return isAddonEnabled(ADDON_IDS.MCP); }
|
||||
mcpSafeUrl(): string { return getMcpSafeUrl(); }
|
||||
|
||||
consumeAuthCode(code: string) { return oauth.consumeAuthCode(code); }
|
||||
authenticateClient(clientId: string, clientSecret?: string) { return oauth.authenticateClient(clientId, clientSecret); }
|
||||
verifyPKCE(verifier: string, challenge: string) { return oauth.verifyPKCE(verifier, challenge); }
|
||||
issueTokens(...args: Parameters<typeof oauth.issueTokens>) { return oauth.issueTokens(...args); }
|
||||
issueClientCredentialsToken(...args: Parameters<typeof oauth.issueClientCredentialsToken>) { return oauth.issueClientCredentialsToken(...args); }
|
||||
refreshTokens(...args: Parameters<typeof oauth.refreshTokens>) { return oauth.refreshTokens(...args); }
|
||||
revokeToken(...args: Parameters<typeof oauth.revokeToken>) { return oauth.revokeToken(...args); }
|
||||
getUserByAccessToken(token: string) { return oauth.getUserByAccessToken(token); }
|
||||
|
||||
validateAuthorizeRequest(params: oauth.AuthorizeParams, userId: number | null) { return oauth.validateAuthorizeRequest(params, userId); }
|
||||
saveConsent(...args: Parameters<typeof oauth.saveConsent>) { return oauth.saveConsent(...args); }
|
||||
createAuthCode(...args: Parameters<typeof oauth.createAuthCode>) { return oauth.createAuthCode(...args); }
|
||||
|
||||
listOAuthClients(userId: number) { return oauth.listOAuthClients(userId); }
|
||||
createOAuthClient(...args: Parameters<typeof oauth.createOAuthClient>) { return oauth.createOAuthClient(...args); }
|
||||
rotateOAuthClientSecret(userId: number, id: string, ip: string | undefined) { return oauth.rotateOAuthClientSecret(userId, id, ip); }
|
||||
deleteOAuthClient(userId: number, id: string, ip: string | undefined) { return oauth.deleteOAuthClient(userId, id, ip); }
|
||||
listOAuthSessions(userId: number) { return oauth.listOAuthSessions(userId); }
|
||||
revokeSession(userId: number, id: number, ip: string | undefined) { return oauth.revokeSession(userId, id, ip); }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
/**
|
||||
* /api/auth/oidc — OIDC SSO login flow (Authorization Code + PKCE).
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/oidc.ts):
|
||||
* unauthenticated, the sso-disabled / not-configured / HTTPS-issuer guards, the
|
||||
* strict id_token + userinfo.sub cross-check, all the frontend redirect error
|
||||
* codes, and the auth-code → cookie hand-off on /exchange. Uses @Res directly
|
||||
* because the flow mixes provider redirects with JSON error bodies.
|
||||
*/
|
||||
@Controller('api/auth/oidc')
|
||||
export class OidcController {
|
||||
constructor(private readonly oidc: OidcService) {}
|
||||
|
||||
@Get('login')
|
||||
async login(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
if (!this.oidc.oidcLoginEnabled()) {
|
||||
res.status(403).json({ error: 'SSO login is disabled.' });
|
||||
return;
|
||||
}
|
||||
const config = this.oidc.getOidcConfig();
|
||||
if (!config) {
|
||||
res.status(400).json({ error: 'OIDC not configured' });
|
||||
return;
|
||||
}
|
||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const doc = await this.oidc.discover(config.issuer, config.discoveryUrl);
|
||||
const appUrl = this.oidc.getAppUrl();
|
||||
if (!appUrl) {
|
||||
res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' });
|
||||
return;
|
||||
}
|
||||
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
|
||||
const inviteToken = req.query.invite as string | undefined;
|
||||
const { state, codeChallenge } = this.oidc.createState(redirectUri, inviteToken);
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: process.env.OIDC_SCOPE || 'openid email profile',
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
res.redirect(`${doc.authorization_endpoint}?${params}`);
|
||||
} catch (err: unknown) {
|
||||
console.error('[OIDC] Login error:', err instanceof Error ? err.message : err);
|
||||
res.status(500).json({ error: 'OIDC login failed' });
|
||||
}
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
async callback(
|
||||
@Query('code') code: string | undefined,
|
||||
@Query('state') state: string | undefined,
|
||||
@Query('error') oidcError: string | undefined,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const f = (p: string) => res.redirect(this.oidc.frontendUrl(p));
|
||||
|
||||
if (!this.oidc.oidcLoginEnabled()) return f('/login?oidc_error=sso_disabled');
|
||||
if (oidcError) {
|
||||
console.error('[OIDC] Provider error:', oidcError);
|
||||
return f('/login?oidc_error=' + encodeURIComponent(oidcError));
|
||||
}
|
||||
if (!code || !state) return f('/login?oidc_error=missing_params');
|
||||
|
||||
const pending = this.oidc.consumeState(state);
|
||||
if (!pending) return f('/login?oidc_error=invalid_state');
|
||||
|
||||
const config = this.oidc.getOidcConfig();
|
||||
if (!config) return f('/login?oidc_error=not_configured');
|
||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
return f('/login?oidc_error=issuer_not_https');
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = await this.oidc.discover(config.issuer, config.discoveryUrl);
|
||||
const tokenData = await this.oidc.exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret, pending.codeVerifier);
|
||||
if (!tokenData._ok || !tokenData.access_token) {
|
||||
console.error('[OIDC] Token exchange failed: status', tokenData._status);
|
||||
return f('/login?oidc_error=token_failed');
|
||||
}
|
||||
if (!tokenData.id_token) {
|
||||
console.error('[OIDC] Token response missing id_token — refusing login');
|
||||
return f('/login?oidc_error=no_id_token');
|
||||
}
|
||||
const idVerify = await this.oidc.verifyIdToken(
|
||||
tokenData.id_token,
|
||||
doc,
|
||||
config.clientId,
|
||||
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
|
||||
);
|
||||
if (idVerify.ok !== true) {
|
||||
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
||||
console.error('[OIDC] id_token verification failed:', reason);
|
||||
return f('/login?oidc_error=id_token_invalid');
|
||||
}
|
||||
const userInfo = await this.oidc.getUserInfo(doc.userinfo_endpoint, tokenData.access_token);
|
||||
if (!userInfo.email) return f('/login?oidc_error=no_email');
|
||||
|
||||
const tokenSub = idVerify.claims.sub;
|
||||
if (typeof tokenSub === 'string' && userInfo.sub && userInfo.sub !== tokenSub) {
|
||||
console.error('[OIDC] userinfo.sub does not match id_token.sub — refusing login');
|
||||
return f('/login?oidc_error=subject_mismatch');
|
||||
}
|
||||
|
||||
const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
|
||||
if ('error' in result) return f('/login?oidc_error=' + result.error);
|
||||
|
||||
this.oidc.touchLastLogin(result.user.id);
|
||||
const jwtToken = this.oidc.generateToken(result.user);
|
||||
const authCode = this.oidc.createAuthCode(jwtToken);
|
||||
return f('/login?oidc_code=' + authCode);
|
||||
} catch (err: unknown) {
|
||||
console.error('[OIDC] Callback error:', err);
|
||||
return f('/login?oidc_error=server_error');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('exchange')
|
||||
exchange(@Query('code') code: string | undefined, @Req() req: Request, @Res() res: Response): void {
|
||||
if (!code) {
|
||||
res.status(400).json({ error: 'Code required' });
|
||||
return;
|
||||
}
|
||||
const result = this.oidc.consumeAuthCode(code);
|
||||
if ('error' in result) {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
this.oidc.setAuthCookie(res, result.token, req);
|
||||
res.json({ token: result.token });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OidcController } from './oidc.controller';
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OidcController],
|
||||
providers: [OidcService],
|
||||
})
|
||||
export class OidcModule {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as oidc from '../../services/oidcService';
|
||||
import { getAppUrl } from '../../services/notifications';
|
||||
import { resolveAuthToggles } from '../../services/authService';
|
||||
import { setAuthCookie } from '../../services/cookie';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing OIDC service. PKCE state, discovery,
|
||||
* the strict id_token/JWKS verification, user provisioning and the auth-code
|
||||
* hand-off all reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
oidcLoginEnabled(): boolean { return resolveAuthToggles().oidc_login; }
|
||||
getOidcConfig() { return oidc.getOidcConfig(); }
|
||||
getAppUrl() { return getAppUrl(); }
|
||||
discover(issuer: string, discoveryUrl?: string | null) { return oidc.discover(issuer, discoveryUrl); }
|
||||
createState(redirectUri: string, inviteToken?: string) { return oidc.createState(redirectUri, inviteToken); }
|
||||
consumeState(state: string) { return oidc.consumeState(state); }
|
||||
exchangeCodeForToken(...args: Parameters<typeof oidc.exchangeCodeForToken>) { return oidc.exchangeCodeForToken(...args); }
|
||||
verifyIdToken(...args: Parameters<typeof oidc.verifyIdToken>) { return oidc.verifyIdToken(...args); }
|
||||
getUserInfo(endpoint: string, accessToken: string) { return oidc.getUserInfo(endpoint, accessToken); }
|
||||
findOrCreateUser(...args: Parameters<typeof oidc.findOrCreateUser>) { return oidc.findOrCreateUser(...args); }
|
||||
touchLastLogin(userId: number) { return oidc.touchLastLogin(userId); }
|
||||
generateToken(user: { id: number }) { return oidc.generateToken(user); }
|
||||
createAuthCode(token: string) { return oidc.createAuthCode(token); }
|
||||
consumeAuthCode(code: string) { return oidc.consumeAuthCode(code); }
|
||||
frontendUrl(path: string) { return oidc.frontendUrl(path); }
|
||||
setAuthCookie(res: Response, token: string, req: Request) { setAuthCookie(res, token, req); }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { PackingService } from './packing.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/packing — trip-scoped packing list (items, bags, templates,
|
||||
* assignees).
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/packing.ts):
|
||||
* every handler verifies trip access (404 "Trip not found"); mutations check the
|
||||
* 'packing_edit' permission (403 "No permission"); status codes match (201 on the
|
||||
* creates, 200 elsewhere — note POST /apply-template stays 200); and the bespoke
|
||||
* 400/404 bodies are reproduced. Mutations broadcast over WebSocket with the
|
||||
* forwarded X-Socket-Id. /reorder is declared before /:id so it wins over the param.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/packing')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PackingController {
|
||||
constructor(private readonly packing: PackingService) {}
|
||||
|
||||
/** Loads the trip or throws the legacy 404; returns it for the permission check. */
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.packing.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: ReturnType<PackingService['verifyTripAccess']>, user: User): void {
|
||||
if (!this.packing.canEdit(trip!, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { items: this.packing.listItems(tripId) };
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
importItems(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body('items') items: unknown,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
throw new HttpException({ error: 'items must be a non-empty array' }, 400);
|
||||
}
|
||||
const created = this.packing.bulkImport(tripId, items);
|
||||
for (const item of created) {
|
||||
this.packing.broadcast(tripId, 'packing:created', { item }, socketId);
|
||||
}
|
||||
return { items: created, count: created.length };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { name?: string; category?: string; checked?: boolean },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.name) {
|
||||
throw new HttpException({ error: 'Item name is required' }, 400);
|
||||
}
|
||||
const item = this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked });
|
||||
this.packing.broadcast(tripId, 'packing:created', { item }, socketId);
|
||||
return { item };
|
||||
}
|
||||
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body('orderedIds') orderedIds: number[],
|
||||
@Headers('x-socket-id') _socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
this.packing.reorderItems(tripId, orderedIds);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { name, checked, category, weight_grams, bag_id, quantity } = body as Record<string, never>;
|
||||
const updated = this.packing.updateItem(tripId, id, { name, checked, category, weight_grams, bag_id, quantity }, Object.keys(body));
|
||||
if (!updated) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
this.packing.broadcast(tripId, 'packing:updated', { item: updated }, socketId);
|
||||
return { item: updated };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.packing.deleteItem(tripId, id)) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
this.packing.broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('bags')
|
||||
listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { bags: this.packing.listBags(tripId) };
|
||||
}
|
||||
|
||||
@Post('bags')
|
||||
createBag(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { name?: string; color?: string },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.name?.trim()) {
|
||||
throw new HttpException({ error: 'Name is required' }, 400);
|
||||
}
|
||||
const bag = this.packing.createBag(tripId, { name: body.name, color: body.color });
|
||||
this.packing.broadcast(tripId, 'packing:bag-created', { bag }, socketId);
|
||||
return { bag };
|
||||
}
|
||||
|
||||
@Put('bags/:bagId')
|
||||
updateBag(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('bagId') bagId: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { name, color, weight_limit_grams, user_id } = body as Record<string, never>;
|
||||
const updated = this.packing.updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(body));
|
||||
if (!updated) {
|
||||
throw new HttpException({ error: 'Bag not found' }, 404);
|
||||
}
|
||||
this.packing.broadcast(tripId, 'packing:bag-updated', { bag: updated }, socketId);
|
||||
return { bag: updated };
|
||||
}
|
||||
|
||||
@Delete('bags/:bagId')
|
||||
deleteBag(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('bagId') bagId: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.packing.deleteBag(tripId, bagId)) {
|
||||
throw new HttpException({ error: 'Bag not found' }, 404);
|
||||
}
|
||||
this.packing.broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('apply-template/:templateId')
|
||||
@HttpCode(200)
|
||||
applyTemplate(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('templateId') templateId: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const added = this.packing.applyTemplate(tripId, templateId);
|
||||
if (!added) {
|
||||
throw new HttpException({ error: 'Template not found or empty' }, 404);
|
||||
}
|
||||
this.packing.broadcast(tripId, 'packing:template-applied', { items: added }, socketId);
|
||||
return { items: added, count: added.length };
|
||||
}
|
||||
|
||||
@Put('bags/:bagId/members')
|
||||
setBagMembers(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('bagId') bagId: string,
|
||||
@Body('user_ids') userIds: unknown,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const members = this.packing.setBagMembers(tripId, bagId, Array.isArray(userIds) ? userIds : []);
|
||||
if (!members) {
|
||||
throw new HttpException({ error: 'Bag not found' }, 404);
|
||||
}
|
||||
this.packing.broadcast(tripId, 'packing:bag-members-updated', { bagId: Number(bagId), members }, socketId);
|
||||
return { members };
|
||||
}
|
||||
|
||||
@Post('save-as-template')
|
||||
saveAsTemplate(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body('name') name?: string,
|
||||
) {
|
||||
this.requireTrip(tripId, user);
|
||||
if (!name?.trim()) {
|
||||
throw new HttpException({ error: 'Template name is required' }, 400);
|
||||
}
|
||||
const template = this.packing.saveAsTemplate(tripId, user.id, name.trim());
|
||||
if (!template) {
|
||||
throw new HttpException({ error: 'No items to save' }, 400);
|
||||
}
|
||||
return { template };
|
||||
}
|
||||
|
||||
@Get('category-assignees')
|
||||
categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { assignees: this.packing.getCategoryAssignees(tripId) };
|
||||
}
|
||||
|
||||
@Put('category-assignees/:categoryName')
|
||||
updateCategoryAssignees(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('categoryName') categoryName: string,
|
||||
@Body('user_ids') userIds: number[],
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const category = decodeURIComponent(categoryName);
|
||||
const rows = this.packing.updateCategoryAssignees(tripId, category, userIds);
|
||||
this.packing.broadcast(tripId, 'packing:assignees', { category, assignees: rows }, socketId);
|
||||
this.packing.notifyTagged(tripId, user, category, userIds);
|
||||
return { assignees: rows };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PackingController } from './packing.controller';
|
||||
import { PackingService } from './packing.service';
|
||||
|
||||
/** Packing domain (S2 — Phase 2 trip sub-domain). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [PackingController],
|
||||
providers: [PackingService],
|
||||
})
|
||||
export class PackingModule {}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/packingService';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing packing service. Trip-access checks, the
|
||||
* 'packing_edit' permission, the item/bag SQL, templates and the WebSocket
|
||||
* broadcasts all reuse the legacy code unchanged, so behaviour is identical.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PackingService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
|
||||
/** Mirrors the inline checkPermission('packing_edit', ...) the legacy route runs. */
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('packing_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
listItems(tripId: string) {
|
||||
return svc.listItems(tripId);
|
||||
}
|
||||
|
||||
createItem(tripId: string, data: { name: string; category?: string; checked?: boolean }) {
|
||||
return svc.createItem(tripId, data);
|
||||
}
|
||||
|
||||
updateItem(tripId: string, id: string, data: Parameters<typeof svc.updateItem>[2], changedKeys: string[]) {
|
||||
return svc.updateItem(tripId, id, data, changedKeys);
|
||||
}
|
||||
|
||||
deleteItem(tripId: string, id: string): boolean {
|
||||
return svc.deleteItem(tripId, id);
|
||||
}
|
||||
|
||||
bulkImport(tripId: string, items: Parameters<typeof svc.bulkImport>[1]) {
|
||||
return svc.bulkImport(tripId, items);
|
||||
}
|
||||
|
||||
reorderItems(tripId: string, orderedIds: Parameters<typeof svc.reorderItems>[1]): void {
|
||||
svc.reorderItems(tripId, orderedIds);
|
||||
}
|
||||
|
||||
listBags(tripId: string) {
|
||||
return svc.listBags(tripId);
|
||||
}
|
||||
|
||||
createBag(tripId: string, data: { name: string; color?: string }) {
|
||||
return svc.createBag(tripId, data);
|
||||
}
|
||||
|
||||
updateBag(tripId: string, bagId: string, data: Parameters<typeof svc.updateBag>[2], changedKeys: string[]) {
|
||||
return svc.updateBag(tripId, bagId, data, changedKeys);
|
||||
}
|
||||
|
||||
deleteBag(tripId: string, bagId: string): boolean {
|
||||
return svc.deleteBag(tripId, bagId);
|
||||
}
|
||||
|
||||
setBagMembers(tripId: string, bagId: string, userIds: number[]) {
|
||||
return svc.setBagMembers(tripId, bagId, userIds);
|
||||
}
|
||||
|
||||
applyTemplate(tripId: string, templateId: string) {
|
||||
return svc.applyTemplate(tripId, templateId);
|
||||
}
|
||||
|
||||
saveAsTemplate(tripId: string, userId: number, name: string) {
|
||||
return svc.saveAsTemplate(tripId, userId, name);
|
||||
}
|
||||
|
||||
getCategoryAssignees(tripId: string) {
|
||||
return svc.getCategoryAssignees(tripId);
|
||||
}
|
||||
|
||||
updateCategoryAssignees(tripId: string, category: string, userIds: number[]) {
|
||||
return svc.updateCategoryAssignees(tripId, category, userIds);
|
||||
}
|
||||
|
||||
/** Fire-and-forget tag notification, mirroring the legacy dynamic import. */
|
||||
notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): void {
|
||||
if (!Array.isArray(userIds) || userIds.length === 0) return;
|
||||
import('../../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
send({
|
||||
event: 'packing_tagged',
|
||||
actorId: actor.id,
|
||||
scope: 'trip',
|
||||
targetId: Number(tripId),
|
||||
params: { trip: tripInfo?.title || 'Untitled', actor: actor.email, category, tripId: String(tripId) },
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Controller, Get, HttpException, Param, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { User } from '../../types';
|
||||
import { PhotosService } from './photos.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/photos/:id/{thumbnail,original,info} — global (not trip-scoped) photo
|
||||
* access for the memories/journey features. Streaming endpoints write straight
|
||||
* to the response via the resolver service.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/photos.ts):
|
||||
* a finite-id guard (400), the canAccessTrekPhoto check (403), then stream or
|
||||
* the provider info (404 inside the service / mapped error for info).
|
||||
*/
|
||||
@Controller('api/photos')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PhotosController {
|
||||
constructor(private readonly photos: PhotosService) {}
|
||||
|
||||
private requireAccess(user: User, rawId: string): number {
|
||||
const photoId = Number(rawId);
|
||||
if (!Number.isFinite(photoId)) {
|
||||
throw new HttpException({ error: 'Invalid photo ID' }, 400);
|
||||
}
|
||||
if (!this.photos.canAccess(user.id, photoId)) {
|
||||
throw new HttpException({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
return photoId;
|
||||
}
|
||||
|
||||
@Get(':id/thumbnail')
|
||||
async thumbnail(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise<void> {
|
||||
const photoId = this.requireAccess(user, id);
|
||||
await this.photos.stream(res, user.id, photoId, 'thumbnail');
|
||||
}
|
||||
|
||||
@Get(':id/original')
|
||||
async original(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise<void> {
|
||||
const photoId = this.requireAccess(user, id);
|
||||
await this.photos.stream(res, user.id, photoId, 'original');
|
||||
}
|
||||
|
||||
@Get(':id/info')
|
||||
async info(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise<void> {
|
||||
const photoId = this.requireAccess(user, id);
|
||||
const result = await this.photos.info(user.id, photoId);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
}
|
||||
res.json(result.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PhotosController } from './photos.controller';
|
||||
import { PhotosService } from './photos.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PhotosController],
|
||||
providers: [PhotosService],
|
||||
})
|
||||
export class PhotosModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { streamPhoto, getPhotoInfo } from '../../services/memories/photoResolverService';
|
||||
import { canAccessTrekPhoto } from '../../services/memories/helpersService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing photo resolver/helper services. Access
|
||||
* control, streaming and the provider-specific info lookups reuse the legacy
|
||||
* code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhotosService {
|
||||
canAccess(userId: number, photoId: number): boolean {
|
||||
return canAccessTrekPhoto(userId, photoId);
|
||||
}
|
||||
|
||||
stream(res: Response, userId: number, photoId: number, kind: 'thumbnail' | 'original') {
|
||||
return streamPhoto(res, userId, photoId, kind);
|
||||
}
|
||||
|
||||
info(userId: number, photoId: number) {
|
||||
return getPhotoInfo(userId, photoId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { User } from '../../types';
|
||||
import { PlacesService } from './places.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
const STRING_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 };
|
||||
const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } };
|
||||
|
||||
function validateLengths(body: Record<string, unknown>): void {
|
||||
for (const [field, max] of Object.entries(STRING_LIMITS)) {
|
||||
const value = body[field];
|
||||
if (value && typeof value === 'string' && value.length > max) {
|
||||
throw new HttpException({ error: `${field} must be ${max} characters or less` }, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseBool(v: unknown, defaultVal: boolean): boolean {
|
||||
return v === undefined || v === null ? defaultVal : String(v) === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/places — the trip's place pool + importers.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/places.ts):
|
||||
* trip access (404) runs first, then the string-length guard (400), then the
|
||||
* 'place_edit' permission (403); create 201 / rest 200; the bespoke 400/404
|
||||
* bodies; the journey create/update/delete hooks; and WebSocket broadcasts with
|
||||
* the forwarded X-Socket-Id. The /import/* and /bulk-delete routes are declared
|
||||
* before /:id so the static segments win over the param.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/places')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PlacesController {
|
||||
constructor(private readonly places: PlacesService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.places.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<PlacesService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.places.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('category') category?: string,
|
||||
@Query('tag') tag?: string,
|
||||
) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { places: this.places.list(tripId, { search, category, tag }) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: Record<string, unknown> & { name?: string },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
validateLengths(body);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.name) {
|
||||
throw new HttpException({ error: 'Place name is required' }, 400);
|
||||
}
|
||||
const place = this.places.create(tripId, body as never);
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
this.places.onCreated(tripId, place.id);
|
||||
return { place };
|
||||
}
|
||||
|
||||
@Post('import/gpx')
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
importGpx(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
const importWaypoints = parseBool(body.importWaypoints, true);
|
||||
const importRoutes = parseBool(body.importRoutes, true);
|
||||
const importTracks = parseBool(body.importTracks, true);
|
||||
if (!importWaypoints && !importRoutes && !importTracks) {
|
||||
throw new HttpException({ error: 'No import types selected' }, 400);
|
||||
}
|
||||
const result = this.places.importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks });
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'No matching places found in GPX file' }, 400);
|
||||
}
|
||||
for (const place of result.places) {
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
}
|
||||
return { places: result.places, count: result.count, skipped: result.skipped };
|
||||
}
|
||||
|
||||
@Post('import/map')
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
async importMap(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
const importPoints = parseBool(body.importPoints, true);
|
||||
const importPaths = parseBool(body.importPaths, true);
|
||||
if (!importPoints && !importPaths) {
|
||||
throw new HttpException({ error: 'No import types selected' }, 400);
|
||||
}
|
||||
try {
|
||||
const result = await this.places.importMapFile(tripId, file.buffer, file.originalname, { importPoints, importPaths });
|
||||
if (result.summary?.totalPlacemarks === 0) {
|
||||
throw new HttpException({ error: 'No valid Placemarks found in map file', summary: result.summary }, 400);
|
||||
}
|
||||
for (const place of result.places) {
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
}
|
||||
return result;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
const message = err instanceof Error ? err.message : 'Failed to import map file';
|
||||
throw new HttpException({ error: message }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('import/google-list')
|
||||
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('google', user, tripId, url, socketId);
|
||||
}
|
||||
|
||||
@Post('import/naver-list')
|
||||
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('naver', user, tripId, url, socketId);
|
||||
}
|
||||
|
||||
/** Shared google/naver list import — identical flow, different provider + error string. */
|
||||
private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new HttpException({ error: 'URL is required' }, 400);
|
||||
}
|
||||
const label = provider === 'google' ? 'Google' : 'Naver';
|
||||
try {
|
||||
const result = provider === 'google'
|
||||
? await this.places.importGoogleList(tripId, url)
|
||||
: await this.places.importNaverList(tripId, url);
|
||||
if ('error' in result) {
|
||||
throw new HttpException({ error: result.error }, result.status);
|
||||
}
|
||||
for (const place of result.places) {
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
}
|
||||
return { places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
console.error(`[Places] ${label} list import error:`, err instanceof Error ? err.message : err);
|
||||
throw new HttpException({ error: `Failed to import ${label} Maps list. Make sure the list is shared publicly.` }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200) // Express answers bulk-delete with res.json (200), unlike the 201 imports.
|
||||
bulkDelete(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body('ids') ids: unknown,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!Array.isArray(ids) || ids.some((v) => typeof v !== 'number')) {
|
||||
throw new HttpException({ error: 'ids must be an array of numbers' }, 400);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
return { deleted: [], count: 0 };
|
||||
}
|
||||
for (const id of ids) this.places.onDeleted(id);
|
||||
const deleted = this.places.removeMany(tripId, ids);
|
||||
for (const id of deleted) {
|
||||
this.places.broadcast(tripId, 'place:deleted', { placeId: id }, socketId);
|
||||
}
|
||||
return { deleted, count: deleted.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
const place = this.places.get(tripId, id);
|
||||
if (!place) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
}
|
||||
return { place };
|
||||
}
|
||||
|
||||
@Get(':id/image')
|
||||
async image(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
try {
|
||||
const result = await this.places.searchImage(tripId, id, user.id);
|
||||
if ('error' in result) {
|
||||
throw new HttpException({ error: result.error }, result.status);
|
||||
}
|
||||
return { photos: result.photos };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
console.error('Unsplash error:', err);
|
||||
throw new HttpException({ error: 'Error searching for image' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
validateLengths(body);
|
||||
this.requireEdit(trip, user);
|
||||
const place = this.places.update(tripId, id, body as never);
|
||||
if (!place) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
}
|
||||
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
|
||||
this.places.onUpdated(place.id);
|
||||
return { place };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
this.places.onDeleted(Number(id)); // sync before actual delete
|
||||
if (!this.places.remove(tripId, id)) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
}
|
||||
this.places.broadcast(tripId, 'place:deleted', { placeId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlacesController } from './places.controller';
|
||||
import { PlacesService } from './places.service';
|
||||
|
||||
/** Places domain (S8 — Phase 2 trip sub-domain). Depends on L4 Categories + L5 Tags. */
|
||||
@Module({
|
||||
controllers: [PlacesController],
|
||||
providers: [PlacesService],
|
||||
})
|
||||
export class PlacesModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/placeService';
|
||||
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../../services/journeyService';
|
||||
|
||||
type Trip = { user_id: number };
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing place service. Trip access mirrors the
|
||||
* requireTripAccess middleware (canAccessTrip); mutations use 'place_edit'. The
|
||||
* SQL, the GPX/map/list importers and the journey hooks reuse the legacy code
|
||||
* unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlacesService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return canAccessTrip(Number(tripId), userId) as Trip | null | undefined;
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('place_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
list(tripId: string, filters: { search?: string; category?: string; tag?: string }) {
|
||||
return svc.listPlaces(tripId, filters);
|
||||
}
|
||||
|
||||
get(tripId: string, id: string) {
|
||||
return svc.getPlace(tripId, id);
|
||||
}
|
||||
|
||||
create(tripId: string, data: Parameters<typeof svc.createPlace>[1]) {
|
||||
return svc.createPlace(tripId, data);
|
||||
}
|
||||
|
||||
update(tripId: string, id: string, data: Parameters<typeof svc.updatePlace>[2]) {
|
||||
return svc.updatePlace(tripId, id, data);
|
||||
}
|
||||
|
||||
remove(tripId: string, id: string): boolean {
|
||||
return svc.deletePlace(tripId, id);
|
||||
}
|
||||
|
||||
removeMany(tripId: string, ids: number[]): number[] {
|
||||
return svc.deletePlacesMany(tripId, ids);
|
||||
}
|
||||
|
||||
importGpx(tripId: string, buffer: Buffer, opts: { importWaypoints: boolean; importRoutes: boolean; importTracks: boolean }) {
|
||||
return svc.importGpx(tripId, buffer, opts);
|
||||
}
|
||||
|
||||
importMapFile(tripId: string, buffer: Buffer, filename: string, opts: svc.KmlImportOptions) {
|
||||
return svc.importMapFile(tripId, buffer, filename, opts);
|
||||
}
|
||||
|
||||
importGoogleList(tripId: string, url: string) {
|
||||
return svc.importGoogleList(tripId, url);
|
||||
}
|
||||
|
||||
importNaverList(tripId: string, url: string) {
|
||||
return svc.importNaverList(tripId, url);
|
||||
}
|
||||
|
||||
searchImage(tripId: string, id: string, userId: number) {
|
||||
return svc.searchPlaceImage(tripId, id, userId);
|
||||
}
|
||||
|
||||
// Journey hooks — non-fatal, mirroring the route's try/catch wrappers.
|
||||
onCreated(tripId: string, placeId: number): void { try { onPlaceCreated(Number(tripId), placeId); } catch { /* non-fatal */ } }
|
||||
onUpdated(placeId: number): void { try { onPlaceUpdated(placeId); } catch { /* non-fatal */ } }
|
||||
onDeleted(placeId: number): void { try { onPlaceDeleted(placeId); } catch { /* non-fatal */ } }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { AccommodationsService } from './accommodations.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
type AccommodationBody = {
|
||||
place_id?: number;
|
||||
start_day_id?: number;
|
||||
end_day_id?: number;
|
||||
check_in?: string | null;
|
||||
check_in_end?: string | null;
|
||||
check_out?: string | null;
|
||||
confirmation?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/accommodations — trip-scoped lodging blocks.
|
||||
*
|
||||
* Byte-identical to the legacy accommodations sub-router (server/src/routes/
|
||||
* days.ts): trip access (404 "Trip not found"), the 'day_edit' permission on
|
||||
* mutations (403), the bespoke 400 (missing refs) and 404 (validateRefs / not
|
||||
* found) bodies, create 201 / rest 200, and the cascade broadcasts (a created
|
||||
* accommodation also emits reservation:created; a delete emits the linked
|
||||
* reservation/budget deletions) with the forwarded X-Socket-Id.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/accommodations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AccommodationsController {
|
||||
constructor(private readonly accommodations: AccommodationsService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.accommodations.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<AccommodationsService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.accommodations.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { accommodations: this.accommodations.list(tripId) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: AccommodationBody,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = body;
|
||||
if (!place_id || !start_day_id || !end_day_id) {
|
||||
throw new HttpException({ error: 'place_id, start_day_id, and end_day_id are required' }, 400);
|
||||
}
|
||||
const errors = this.accommodations.validateRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) {
|
||||
throw new HttpException({ error: errors[0].message }, 404);
|
||||
}
|
||||
const accommodation = this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
this.accommodations.broadcast(tripId, 'accommodation:created', { accommodation }, socketId);
|
||||
this.accommodations.broadcast(tripId, 'reservation:created', {}, socketId);
|
||||
return { accommodation };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: AccommodationBody,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const existing = this.accommodations.get(id, tripId);
|
||||
if (!existing) {
|
||||
throw new HttpException({ error: 'Accommodation not found' }, 404);
|
||||
}
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = body;
|
||||
const errors = this.accommodations.validateRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) {
|
||||
throw new HttpException({ error: errors[0].message }, 404);
|
||||
}
|
||||
const accommodation = this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
this.accommodations.broadcast(tripId, 'accommodation:updated', { accommodation }, socketId);
|
||||
return { accommodation };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.accommodations.get(id, tripId)) {
|
||||
throw new HttpException({ error: 'Accommodation not found' }, 404);
|
||||
}
|
||||
const { linkedReservationId, deletedBudgetItemId } = this.accommodations.remove(id);
|
||||
if (linkedReservationId) {
|
||||
this.accommodations.broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }, socketId);
|
||||
}
|
||||
if (deletedBudgetItemId) {
|
||||
this.accommodations.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, socketId);
|
||||
}
|
||||
this.accommodations.broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as dayService from '../../services/dayService';
|
||||
|
||||
type Trip = { user_id: number };
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the accommodation parts of the existing day service.
|
||||
* Accommodations are gated by the 'day_edit' permission (same as days) and the
|
||||
* SQL + cascade (linked reservation / budget cleanup on delete) reuse the legacy
|
||||
* code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccommodationsService {
|
||||
/** Mirrors the requireTripAccess middleware (owner or member), returning the trip. */
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return canAccessTrip(Number(tripId), userId) as Trip | null | undefined;
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
list(tripId: string) {
|
||||
return dayService.listAccommodations(tripId);
|
||||
}
|
||||
|
||||
validateRefs(tripId: string, placeId?: number, startDayId?: number, endDayId?: number) {
|
||||
return dayService.validateAccommodationRefs(tripId, placeId, startDayId, endDayId);
|
||||
}
|
||||
|
||||
get(id: string, tripId: string) {
|
||||
return dayService.getAccommodation(id, tripId);
|
||||
}
|
||||
|
||||
create(tripId: string, data: Parameters<typeof dayService.createAccommodation>[1]) {
|
||||
return dayService.createAccommodation(tripId, data);
|
||||
}
|
||||
|
||||
update(id: string, existing: Parameters<typeof dayService.updateAccommodation>[1], fields: Parameters<typeof dayService.updateAccommodation>[2]) {
|
||||
return dayService.updateAccommodation(id, existing, fields);
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return dayService.deleteAccommodation(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
type ReservationBody = Record<string, unknown> & {
|
||||
title?: string;
|
||||
type?: string;
|
||||
create_budget_entry?: { total_price?: number; category?: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/reservations — trip-scoped bookings.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/reservations.ts):
|
||||
* trip access (404), 'reservation_edit' permission (403), create 201 / rest 200,
|
||||
* the bespoke 400/404 bodies, the accommodation + budget side effects, the
|
||||
* booking notifications, and all WebSocket broadcasts with the forwarded
|
||||
* X-Socket-Id. /positions is declared before /:id so it wins over the param.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/reservations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReservationsController {
|
||||
constructor(private readonly reservations: ReservationsService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.reservations.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: ReturnType<ReservationsService['verifyTripAccess']>, user: User): void {
|
||||
if (!this.reservations.canEdit(trip!, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { reservations: this.reservations.list(tripId) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: ReservationBody,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.title) {
|
||||
throw new HttpException({ error: 'Title is required' }, 400);
|
||||
}
|
||||
const { reservation, accommodationCreated } = this.reservations.create(tripId, body as never);
|
||||
if (accommodationCreated) {
|
||||
this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId);
|
||||
}
|
||||
this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId);
|
||||
this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user, body.title, body.type ?? '');
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
@Put('positions')
|
||||
updatePositions(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { positions?: unknown; day_id?: unknown },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!Array.isArray(body.positions)) {
|
||||
throw new HttpException({ error: 'positions must be an array' }, 400);
|
||||
}
|
||||
this.reservations.updatePositions(tripId, body.positions, body.day_id);
|
||||
this.reservations.broadcast(tripId, 'reservation:positions', { positions: body.positions, day_id: body.day_id }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: ReservationBody,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const current = this.reservations.getReservation(id, tripId);
|
||||
if (!current) {
|
||||
throw new HttpException({ error: 'Reservation not found' }, 404);
|
||||
}
|
||||
const { reservation, accommodationChanged } = this.reservations.update(id, tripId, body as never, current as never);
|
||||
if (accommodationChanged) {
|
||||
this.reservations.broadcast(tripId, 'accommodation:updated', {}, socketId);
|
||||
}
|
||||
const cur = current as { title: string; type?: string };
|
||||
this.reservations.syncBudgetOnUpdate(tripId, id, body.title ?? '', body.type, cur.title, cur.type, body.create_budget_entry, socketId);
|
||||
this.reservations.broadcast(tripId, 'reservation:updated', { reservation }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user, body.title || cur.title, body.type || cur.type || '');
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { deleted, accommodationDeleted, deletedBudgetItemId } = this.reservations.remove(id, tripId);
|
||||
if (!deleted) {
|
||||
throw new HttpException({ error: 'Reservation not found' }, 404);
|
||||
}
|
||||
if (accommodationDeleted) {
|
||||
this.reservations.broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, socketId);
|
||||
}
|
||||
if (deletedBudgetItemId) {
|
||||
this.reservations.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, socketId);
|
||||
}
|
||||
this.reservations.broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user, deleted.title, deleted.type || '');
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReservationsController } from './reservations.controller';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
import { AccommodationsController } from './accommodations.controller';
|
||||
import { AccommodationsService } from './accommodations.service';
|
||||
|
||||
/**
|
||||
* Reservations + accommodations domain (S5 — Phase 2 trip sub-domain).
|
||||
* Two mounts: /api/trips/:tripId/reservations and /accommodations.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [ReservationsController, AccommodationsController],
|
||||
providers: [ReservationsService, AccommodationsService],
|
||||
})
|
||||
export class ReservationsModule {}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/reservationService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing reservation service. Trip-access, the
|
||||
* 'reservation_edit' permission, the SQL and the WebSocket broadcasts reuse the
|
||||
* legacy code unchanged. The legacy route's budget side effects (auto-create /
|
||||
* update / delete a linked budget item) and the booking notification are
|
||||
* encapsulated here so the controller stays thin — behaviour is 1:1.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReservationsService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
list(tripId: string) {
|
||||
return svc.listReservations(tripId);
|
||||
}
|
||||
|
||||
create(tripId: string, data: Parameters<typeof svc.createReservation>[1]) {
|
||||
return svc.createReservation(tripId, data);
|
||||
}
|
||||
|
||||
updatePositions(tripId: string, positions: Parameters<typeof svc.updatePositions>[1], dayId: unknown): void {
|
||||
svc.updatePositions(tripId, positions, dayId as Parameters<typeof svc.updatePositions>[2]);
|
||||
}
|
||||
|
||||
getReservation(id: string, tripId: string) {
|
||||
return svc.getReservation(id, tripId);
|
||||
}
|
||||
|
||||
update(id: string, tripId: string, data: Parameters<typeof svc.updateReservation>[2], current: Parameters<typeof svc.updateReservation>[3]) {
|
||||
return svc.updateReservation(id, tripId, data, current);
|
||||
}
|
||||
|
||||
remove(id: string, tripId: string) {
|
||||
return svc.deleteReservation(id, tripId);
|
||||
}
|
||||
|
||||
/** POST side effect: auto-create a linked budget item when a price is provided. */
|
||||
syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
||||
if (!entry || !(Number(entry.total_price) > 0)) return;
|
||||
try {
|
||||
const item = linkBudgetItemToReservation(tripId, reservationId, {
|
||||
name: title,
|
||||
category: entry.category || type || 'Other',
|
||||
total_price: entry.total_price!,
|
||||
});
|
||||
broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
} catch (err) {
|
||||
console.error('[reservations] Failed to create budget entry:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */
|
||||
syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
||||
if (!entry || !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;
|
||||
if (linked) {
|
||||
deleteBudgetItem(linked.id, tripId);
|
||||
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
|
||||
}
|
||||
}
|
||||
if (entry && Number(entry.total_price) > 0) {
|
||||
try {
|
||||
const itemName = title || currentTitle;
|
||||
const category = entry.category || type || currentType || 'Other';
|
||||
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, category, total_price: entry.total_price });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
} else {
|
||||
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
|
||||
item.reservation_id = Number(id);
|
||||
broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[reservations] Failed to create/update budget entry:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */
|
||||
notifyBookingChange(tripId: string, actor: User, booking: string, type: string): void {
|
||||
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: actor.id,
|
||||
scope: 'trip',
|
||||
targetId: Number(tripId),
|
||||
params: { trip: tripInfo?.title || 'Untitled', actor: actor.email, booking, type: type || 'booking', tripId: String(tripId) },
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpException, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
const MASKED_VALUE = '••••••••';
|
||||
|
||||
/**
|
||||
* /api/settings — per-user key/value preferences.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/settings.ts):
|
||||
* get-all, single upsert (400 without a key, no-op on the masked sentinel), and
|
||||
* bulk upsert (400 without an object, 500 on a write error). All answer 200.
|
||||
*/
|
||||
@Controller('api/settings')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SettingsController {
|
||||
constructor(private readonly settings: SettingsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User) {
|
||||
return { settings: this.settings.getUserSettings(user.id) };
|
||||
}
|
||||
|
||||
@Put()
|
||||
upsert(@CurrentUser() user: User, @Body() body: { key?: string; value?: unknown }) {
|
||||
if (!body.key) {
|
||||
throw new HttpException({ error: 'Key is required' }, 400);
|
||||
}
|
||||
// The client echoes a redacted secret back unchanged — treat as a no-op.
|
||||
if (body.value === MASKED_VALUE) {
|
||||
return { success: true, key: body.key, unchanged: true };
|
||||
}
|
||||
this.settings.upsertSetting(user.id, body.key, body.value);
|
||||
return { success: true, key: body.key, value: body.value };
|
||||
}
|
||||
|
||||
@Post('bulk')
|
||||
@HttpCode(200) // Express answers bulk with res.json (200), not the POST-default 201.
|
||||
bulk(@CurrentUser() user: User, @Body() body: { settings?: unknown }) {
|
||||
if (!body.settings || typeof body.settings !== 'object') {
|
||||
throw new HttpException({ error: 'Settings object is required' }, 400);
|
||||
}
|
||||
try {
|
||||
const updated = this.settings.bulkUpsertSettings(user.id, body.settings as Record<string, unknown>);
|
||||
return { success: true, updated };
|
||||
} catch (err) {
|
||||
console.error('Error saving settings:', err);
|
||||
throw new HttpException({ error: 'Error saving settings' }, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SettingsController } from './settings.controller';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SettingsController],
|
||||
providers: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as svc from '../../services/settingsService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing settings service. The key/value SQL and
|
||||
* secret-redaction reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
getUserSettings(userId: number) { return svc.getUserSettings(userId); }
|
||||
upsertSetting(userId: number, key: string, value: unknown) { return svc.upsertSetting(userId, key, value); }
|
||||
bulkUpsertSettings(userId: number, settings: Record<string, unknown>) { return svc.bulkUpsertSettings(userId, settings); }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Body, Controller, Delete, Get, HttpException, Param, Post, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { User } from '../../types';
|
||||
import { ShareService } from './share.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/share-link — manage a trip's public read-only share token.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/share.ts): trip
|
||||
* access (404), the 'share_manage' permission (403), and the create-vs-update
|
||||
* status split (201 on first creation, 200 on a subsequent update).
|
||||
*/
|
||||
@Controller('api/trips/:tripId/share-link')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TripShareController {
|
||||
constructor(private readonly share: ShareService) {}
|
||||
|
||||
private requireManage(tripId: string, user: User) {
|
||||
const trip = this.share.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
if (!this.share.canManage(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { share_map?: boolean; share_bookings?: boolean; share_packing?: boolean; share_budget?: boolean; share_collab?: boolean },
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
this.requireManage(tripId, user);
|
||||
const result = this.share.createOrUpdate(tripId, user.id, {
|
||||
share_map: body.share_map,
|
||||
share_bookings: body.share_bookings,
|
||||
share_packing: body.share_packing,
|
||||
share_budget: body.share_budget,
|
||||
share_collab: body.share_collab,
|
||||
});
|
||||
// 201 only on first creation; an update answers 200, mirroring the legacy route.
|
||||
res.status(result.created ? 201 : 200);
|
||||
return { token: result.token };
|
||||
}
|
||||
|
||||
@Get()
|
||||
get(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
if (!this.share.verifyTripAccess(tripId, user.id)) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
const info = this.share.get(tripId);
|
||||
return info ? info : { token: null };
|
||||
}
|
||||
|
||||
@Delete()
|
||||
remove(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireManage(tripId, user);
|
||||
this.share.remove(tripId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/shared/:token — public, unauthenticated read-only trip snapshot.
|
||||
* Deliberately NOT behind a guard; an invalid/expired token answers 404.
|
||||
*/
|
||||
@Controller('api/shared')
|
||||
export class SharedController {
|
||||
constructor(private readonly share: ShareService) {}
|
||||
|
||||
@Get(':token')
|
||||
read(@Param('token') token: string) {
|
||||
const data = this.share.getSharedTripData(token);
|
||||
if (!data) {
|
||||
throw new HttpException({ error: 'Invalid or expired link' }, 404);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TripShareController, SharedController } from './share.controller';
|
||||
import { ShareService } from './share.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TripShareController, SharedController],
|
||||
providers: [ShareService],
|
||||
})
|
||||
export class ShareModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/shareService';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof canAccessTrip>>;
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing share service. Trip access, the
|
||||
* 'share_manage' permission and the token SQL reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
canManage(trip: Trip, user: User): boolean {
|
||||
return checkPermission('share_manage', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
createOrUpdate(tripId: string, userId: number, permissions: Parameters<typeof svc.createOrUpdateShareLink>[2]) {
|
||||
return svc.createOrUpdateShareLink(tripId, userId, permissions);
|
||||
}
|
||||
get(tripId: string) { return svc.getShareLink(tripId); }
|
||||
remove(tripId: string) { return svc.deleteShareLink(tripId); }
|
||||
getSharedTripData(token: string) { return svc.getSharedTripData(token); }
|
||||
}
|
||||
@@ -8,7 +8,73 @@
|
||||
* rollback — no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes
|
||||
* everything back to the legacy app.
|
||||
*/
|
||||
const DEFAULT_NEST_PREFIXES = ['/api/_nest', '/api/weather'];
|
||||
const DEFAULT_NEST_PREFIXES = [
|
||||
'/api/_nest',
|
||||
'/api/weather',
|
||||
'/api/airports',
|
||||
'/api/config',
|
||||
'/api/system-notices',
|
||||
'/api/maps',
|
||||
'/api/categories',
|
||||
'/api/tags',
|
||||
'/api/notifications',
|
||||
'/api/addons/atlas',
|
||||
'/api/addons/vacay',
|
||||
'/api/trips/:tripId/packing',
|
||||
'/api/trips/:tripId/todo',
|
||||
'/api/trips/:tripId/budget',
|
||||
'/api/trips/:tripId/reservations',
|
||||
'/api/trips/:tripId/accommodations',
|
||||
'/api/trips/:tripId/days',
|
||||
'/api/trips/:tripId/assignments',
|
||||
'/api/trips/:tripId/places',
|
||||
'/api/trips/:tripId/collab',
|
||||
'/api/trips/:tripId/files',
|
||||
'/api/photos',
|
||||
'/api/journeys',
|
||||
'/api/public/journey',
|
||||
'/api/shared',
|
||||
'/api/settings',
|
||||
'/api/backup',
|
||||
// Auth — listed as explicit sub-paths (rather than one broad /api/auth prefix)
|
||||
// so each endpoint was flipped to Nest individually as it was migrated. All
|
||||
// current /api/auth/* endpoints below, including /api/auth/oidc, are handled
|
||||
// by Nest; nothing here falls through to Express anymore.
|
||||
'/api/auth/app-config',
|
||||
'/api/auth/demo-login',
|
||||
'/api/auth/invite',
|
||||
'/api/auth/register',
|
||||
'/api/auth/login',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/me',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/avatar',
|
||||
'/api/auth/users',
|
||||
'/api/auth/validate-keys',
|
||||
'/api/auth/app-settings',
|
||||
'/api/auth/travel-stats',
|
||||
'/api/auth/mfa',
|
||||
'/api/auth/mcp-tokens',
|
||||
'/api/auth/ws-token',
|
||||
'/api/auth/resource-token',
|
||||
'/api/auth/oidc',
|
||||
'/api/oauth',
|
||||
// OAuth public endpoints — explicit so the SDK-mounted /oauth/authorize,
|
||||
// /oauth/register and /oauth/consent keep falling through to Express.
|
||||
'/oauth/token',
|
||||
'/oauth/userinfo',
|
||||
'/oauth/revoke',
|
||||
'/api/admin',
|
||||
'/api/trips/:tripId/share-link',
|
||||
'/api/trips|',
|
||||
'/api/trips/:tripId|',
|
||||
'/api/trips/:tripId/members',
|
||||
'/api/trips/:tripId/cover',
|
||||
'/api/trips/:tripId/copy',
|
||||
'/api/trips/:tripId/bundle',
|
||||
'/api/trips/:tripId/export.ics',
|
||||
];
|
||||
|
||||
export function getNestPrefixes(): string[] {
|
||||
const raw = process.env.NEST_PREFIXES;
|
||||
@@ -18,7 +84,43 @@ export function getNestPrefixes(): string[] {
|
||||
return DEFAULT_NEST_PREFIXES;
|
||||
}
|
||||
|
||||
function escapeRegExp(segment: string): string {
|
||||
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one prefix into a matcher.
|
||||
*
|
||||
* - A static prefix (no `:param`) uses a plain exact/sub-path match.
|
||||
* - A pattern prefix containing `:param` segments — needed for trip-scoped
|
||||
* routes like `/api/trips/:tripId/packing`, where the legacy mount sits
|
||||
* between dynamic ids — compiles to a regex in which each `:param` matches
|
||||
* exactly one path segment, so a single nested mount routes to Nest without
|
||||
* capturing sibling routes (days, places, ...) still served by Express.
|
||||
* - A trailing `|` marks the prefix as EXACT — it matches that path only, NOT
|
||||
* its sub-paths. This is what lets an aggregate-root like `/api/trips` migrate
|
||||
* (its own /api/trips and /api/trips/:id routes) without swallowing the
|
||||
* not-yet-migrated nested mounts (/api/trips/:id/collab, /files, ...).
|
||||
*/
|
||||
function prefixToMatcher(prefix: string): (path: string) => boolean {
|
||||
const exact = prefix.endsWith('|');
|
||||
const p = exact ? prefix.slice(0, -1) : prefix;
|
||||
|
||||
if (!p.includes(':')) {
|
||||
if (exact) return (path) => path === p;
|
||||
return (path) => path === p || path.startsWith(p + '/');
|
||||
}
|
||||
|
||||
const pattern = p
|
||||
.split('/')
|
||||
.map((segment) => (segment.startsWith(':') ? '[^/]+' : escapeRegExp(segment)))
|
||||
.join('/');
|
||||
const re = new RegExp(exact ? `^${pattern}$` : `^${pattern}(?:/.*)?$`);
|
||||
return (path) => re.test(path);
|
||||
}
|
||||
|
||||
/** Builds a matcher: true when `path` belongs to one of the migrated prefixes. */
|
||||
export function makeNestPathMatcher(prefixes: string[]): (path: string) => boolean {
|
||||
return (path) => prefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/'));
|
||||
const matchers = prefixes.map(prefixToMatcher);
|
||||
return (path) => matchers.some((matches) => matches(path));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, HttpCode, HttpException, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import type { SystemNoticeDto } from '@trek/shared';
|
||||
import type { User } from '../../types';
|
||||
import { SystemNoticesService } from './system-notices.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/system-notices — active announcements for the current user + dismissal.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/systemNotices.ts):
|
||||
* both endpoints require auth, `/active` returns the evaluated DTO list, and
|
||||
* dismiss is idempotent — an unknown id 404s with `{ error: 'NOTICE_NOT_FOUND' }`
|
||||
* and a successful dismiss returns 204 with no body.
|
||||
*/
|
||||
@Controller('api/system-notices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SystemNoticesController {
|
||||
constructor(private readonly notices: SystemNoticesService) {}
|
||||
|
||||
@Get('active')
|
||||
active(@CurrentUser() user: User): SystemNoticeDto[] {
|
||||
return this.notices.getActiveFor(user.id);
|
||||
}
|
||||
|
||||
@Post(':id/dismiss')
|
||||
@HttpCode(204)
|
||||
dismiss(@CurrentUser() user: User, @Param('id') id: string): void {
|
||||
const dismissed = this.notices.dismiss(user.id, id);
|
||||
if (!dismissed) {
|
||||
throw new HttpException({ error: 'NOTICE_NOT_FOUND' }, 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SystemNoticesController } from './system-notices.controller';
|
||||
import { SystemNoticesService } from './system-notices.service';
|
||||
|
||||
/** System-notices domain (L2 leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [SystemNoticesController],
|
||||
providers: [SystemNoticesService],
|
||||
})
|
||||
export class SystemNoticesModule {}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { SystemNoticeDto } from '@trek/shared';
|
||||
import { getActiveNoticesFor, dismissNotice } from '../../systemNotices/service';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing system-notices service. The condition
|
||||
* evaluation, version gating, sorting and dismissal persistence all stay in the
|
||||
* upstream service — this only adapts it for DI, so behaviour is unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SystemNoticesService {
|
||||
getActiveFor(userId: number): SystemNoticeDto[] {
|
||||
return getActiveNoticesFor(userId) as SystemNoticeDto[];
|
||||
}
|
||||
|
||||
dismiss(userId: number, noticeId: string): boolean {
|
||||
return dismissNotice(userId, noticeId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Body, Controller, Delete, Get, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import type { Tag, TagListResponse } from '@trek/shared';
|
||||
import type { User } from '../../types';
|
||||
import { TagsService } from './tags.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/tags — per-user place-tag CRUD.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/tags.ts): every
|
||||
* endpoint requires auth and is scoped to the caller's own tags. Update/delete
|
||||
* verify ownership via getTagByIdAndUser and 404 otherwise. Status codes match
|
||||
* the Nest defaults the legacy route used (201 on create, 200 elsewhere); the
|
||||
* bespoke 400/404 bodies are reproduced exactly.
|
||||
*/
|
||||
@Controller('api/tags')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TagsController {
|
||||
constructor(private readonly tags: TagsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User): TagListResponse {
|
||||
return { tags: this.tags.list(user.id) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Body('name') name?: string,
|
||||
@Body('color') color?: string,
|
||||
): { tag: Tag } {
|
||||
if (!name) {
|
||||
throw new HttpException({ error: 'Tag name is required' }, 400);
|
||||
}
|
||||
return { tag: this.tags.create(user.id, name, color) };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body('name') name?: string,
|
||||
@Body('color') color?: string,
|
||||
): { tag: Tag } {
|
||||
if (!this.tags.getByIdAndUser(id, user.id)) {
|
||||
throw new HttpException({ error: 'Tag not found' }, 404);
|
||||
}
|
||||
return { tag: this.tags.update(id, name, color) };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: User, @Param('id') id: string): { success: boolean } {
|
||||
if (!this.tags.getByIdAndUser(id, user.id)) {
|
||||
throw new HttpException({ error: 'Tag not found' }, 404);
|
||||
}
|
||||
this.tags.remove(id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagsController } from './tags.controller';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
/** Tags domain (L5 leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService],
|
||||
})
|
||||
export class TagsModule {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Tag } from '@trek/shared';
|
||||
import {
|
||||
listTags,
|
||||
createTag,
|
||||
getTagByIdAndUser,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
} from '../../services/tagService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing tag service. Ownership scoping and the
|
||||
* default colour fallback stay in tagService, so behaviour is unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
list(userId: number): Tag[] {
|
||||
return listTags(userId) as Tag[];
|
||||
}
|
||||
|
||||
getByIdAndUser(id: string | number, userId: number): Tag | undefined {
|
||||
return getTagByIdAndUser(id, userId) as Tag | undefined;
|
||||
}
|
||||
|
||||
create(userId: number, name: string, color?: string): Tag {
|
||||
return createTag(userId, name, color) as Tag;
|
||||
}
|
||||
|
||||
update(id: string | number, name?: string, color?: string): Tag {
|
||||
return updateTag(id, name, color) as Tag;
|
||||
}
|
||||
|
||||
remove(id: string | number): void {
|
||||
deleteTag(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { TodoService } from './todo.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/todo — trip-scoped task list.
|
||||
*
|
||||
* Byte-identical to the legacy Express route (server/src/routes/todo.ts): every
|
||||
* handler verifies trip access (404); mutations check the 'packing_edit'
|
||||
* permission (403); create is 201, the rest 200; the bespoke 400/404 bodies are
|
||||
* reproduced; mutations broadcast over WebSocket with the forwarded X-Socket-Id.
|
||||
* /reorder is declared before /:id so it wins over the param.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/todo')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TodoController {
|
||||
constructor(private readonly todo: TodoService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.todo.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: ReturnType<TodoService['verifyTripAccess']>, user: User): void {
|
||||
if (!this.todo.canEdit(trip!, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { items: this.todo.listItems(tripId) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { name?: string; category?: string; due_date?: string; description?: string; assigned_user_id?: number; priority?: number },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.name) {
|
||||
throw new HttpException({ error: 'Item name is required' }, 400);
|
||||
}
|
||||
const { name, category, due_date, description, assigned_user_id, priority } = body;
|
||||
const item = this.todo.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
|
||||
this.todo.broadcast(tripId, 'todo:created', { item }, socketId);
|
||||
return { item };
|
||||
}
|
||||
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body('orderedIds') orderedIds: number[],
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
this.todo.reorderItems(tripId, orderedIds);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { name, checked, category, due_date, description, assigned_user_id, priority } = body as Record<string, never>;
|
||||
const updated = this.todo.updateItem(tripId, id, { name, checked, category, due_date, description, assigned_user_id, priority }, Object.keys(body));
|
||||
if (!updated) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
this.todo.broadcast(tripId, 'todo:updated', { item: updated }, socketId);
|
||||
return { item: updated };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.todo.deleteItem(tripId, id)) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
this.todo.broadcast(tripId, 'todo:deleted', { itemId: Number(id) }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('category-assignees')
|
||||
categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { assignees: this.todo.getCategoryAssignees(tripId) };
|
||||
}
|
||||
|
||||
@Put('category-assignees/:categoryName')
|
||||
updateCategoryAssignees(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('categoryName') categoryName: string,
|
||||
@Body('user_ids') userIds: number[],
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const category = decodeURIComponent(categoryName);
|
||||
const rows = this.todo.updateCategoryAssignees(tripId, category, userIds);
|
||||
this.todo.broadcast(tripId, 'todo:assignees', { category, assignees: rows }, socketId);
|
||||
return { assignees: rows };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TodoController } from './todo.controller';
|
||||
import { TodoService } from './todo.service';
|
||||
|
||||
/** To-do domain (S3 — Phase 2 trip sub-domain). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [TodoController],
|
||||
providers: [TodoService],
|
||||
})
|
||||
export class TodoModule {}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/todoService';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing todo service. Trip-access, the
|
||||
* 'packing_edit' permission (shared with packing), the SQL and the WebSocket
|
||||
* broadcasts all reuse the legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TodoService {
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
return checkPermission('packing_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
listItems(tripId: string) {
|
||||
return svc.listItems(tripId);
|
||||
}
|
||||
|
||||
createItem(tripId: string, data: Parameters<typeof svc.createItem>[1]) {
|
||||
return svc.createItem(tripId, data);
|
||||
}
|
||||
|
||||
updateItem(tripId: string, id: string, data: Parameters<typeof svc.updateItem>[2], changedKeys: string[]) {
|
||||
return svc.updateItem(tripId, id, data, changedKeys);
|
||||
}
|
||||
|
||||
deleteItem(tripId: string, id: string): boolean {
|
||||
return svc.deleteItem(tripId, id);
|
||||
}
|
||||
|
||||
reorderItems(tripId: string, orderedIds: Parameters<typeof svc.reorderItems>[1]): void {
|
||||
svc.reorderItems(tripId, orderedIds);
|
||||
}
|
||||
|
||||
getCategoryAssignees(tripId: string) {
|
||||
return svc.getCategoryAssignees(tripId);
|
||||
}
|
||||
|
||||
updateCategoryAssignees(tripId: string, category: string, userIds: number[]) {
|
||||
return svc.updateCategoryAssignees(tripId, category, userIds);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user