Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087)

* 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.

* Finish the NestJS migration — drop the legacy Express app

NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.

- Platform/transport routes extracted to nest/platform/platform.routes.ts
  and mounted before app.init() — Nest's router answers an unmatched
  request with a 404, so a route registered after init is never reached.
  The SPA fallback is a NotFoundException filter and the catch-all uses a
  RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
  gallery's Immich/Synology proxy), addons (GET /api/addons) and the
  cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
  so upload rejections keep their 400/413 { error } body and non-ASCII
  filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
  trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
  app; removed the now-meaningless Express-vs-Nest parity tests and a few
  orphaned client components.

* Restore the reset-password rate limit and fix copyTrip reservation links

Two correctness/security gaps the NestJS migration introduced:

- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
  (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
  so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
  remapped through dayMap like day_id) or needs_review, so a duplicated trip
  lost multi-day transport end-day links and reset the review flag.

* Clean up dead code, dedupe helpers, fix the reset-password contract

- Remove server exports orphaned by the Express removal: the immich
  album-link helpers, seven route-only service exports, getFileByIdFull;
  de-export internal-only helpers (utcSuffix).
- De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts)
  and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost
  (BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged.
- resetPasswordRequestSchema declared `password`, but the client sends and
  the service reads `new_password` — rename it so the contract matches and
  the client types resolve.
- Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of
  fetching ~4600 features from GitHub during the test (it hung the suite).

* Make the client typecheck runnable (vitest/vite ambient types)

The client had no `typecheck` script and tsc couldn't even start (the
baseUrl deprecation errored out, same as server/shared already silence).
Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck`
npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals
so tsc knows the test globals (describe/it/expect/vi). This turns ~3600
phantom "Cannot find name" errors into a real, measurable count (~590 actual
type errors remain, to be worked down). Type-only; no runtime change.

* Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.

* chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations

The migration that disables the legacy "memories" addon swallowed any
error in an empty catch, as did ~30 other catch blocks in the migration
runner (column adds, the journey rebuild, index probes). Replace each
silent catch with the existing console.warn('[migrations] ...') log so
failures are visible. Control flow is unchanged: every step stays
non-fatal, nothing new is thrown.

Add a static guardrail test that scans the migration source and fails
when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE /
DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and
when an empty/silent catch block is reintroduced. The existing
destructive statements are all legitimate table rebuilds or
bounded cleanups and are recorded in the allowlist with a reason.

* Re-check SSRF on every redirect hop when resolving short links

Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.

* Reject WebSocket tokens minted before a password change

Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.

* fix(i18n): guard locale key parity and finish the OAuth consent page strings

Every non-en locale now exposes the exact same flat key set as en. Keys that
had drifted out of sync are backfilled with the English source value (tagged
en-fallback) so t() resolves a real string instead of relying on the silent
runtime fallback; no existing translation was touched and no key was removed.

Add a parity test that imports each aggregated locale bundle and asserts its
key set matches en, with a diagnostic listing of any missing/extra keys. This
complements the file-level check in shared/scripts by guarding the merged
export the app actually serves.

Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded
English chrome strings now go through oauth.authorize.* keys (English source
in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.

* Add semantic theme color tokens to Tailwind

Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values.

* Surface silent store failures to the user and validate API responses in dev

Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.

* Migrate static theme inline styles to Tailwind utilities and extract page sub-components

Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.

* Remove the unrouted photos page and its dead photo components

PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.

* Resolve the remaining client type errors and the trip.title navbar bug

Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.

* Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities

Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead.

Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue).

* Add eslint flat-config for client and server and gate typecheck, lint and pages in CI

client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking.

* Decompose the remaining God Components into hooks, helpers and sub-components

FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.

* Fix duplicate React keys in the file-assign place list

When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged.

* Format the shared package and drop an unused import to satisfy the lint gate

The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.

* Install all workspaces in the server CI job so SWC's native binary is present

The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'.

* Re-resolve dependencies with npm install in the server CI job for SWC

Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts.

* Install @swc/core's Linux binary explicitly in the server CI job

Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config.

* Use legacy-peer-deps when installing the SWC Linux binary in CI

The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary.

* Keep the lockfile when installing the SWC binary so other deps stay pinned

Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version.
This commit is contained in:
Maurice
2026-05-31 21:10:00 +02:00
committed by GitHub
parent 6d2dd37414
commit 20791a29a7
721 changed files with 44416 additions and 31919 deletions
-527
View File
@@ -1,527 +0,0 @@
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 { db } from './db/database';
import authRoutes from './routes/auth';
import tripsRoutes from './routes/trips';
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
import placesRoutes from './routes/places';
import assignmentsRoutes from './routes/assignments';
import packingRoutes from './routes/packing';
import todoRoutes from './routes/todo';
import tagsRoutes from './routes/tags';
import categoriesRoutes from './routes/categories';
import adminRoutes from './routes/admin';
import mapsRoutes from './routes/maps';
import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes';
import settingsRoutes from './routes/settings';
import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab';
import backupRoutes from './routes/backup';
import oidcRoutes from './routes/oidc';
import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
import vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified';
import photoRoutes from './routes/photos';
import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey';
import journeyPublicRoutes from './routes/journeyPublic';
import publicConfigRoutes from './routes/publicConfig';
import systemNoticesRoutes from './routes/systemNotices';
import { mcpHandler } from './mcp';
import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider';
import { Addon, AuthRequest } from './types';
import { getUpcomingReservations } from './services/reservationService';
import { getPhotoProviderConfig } from './services/memories/helpersService';
import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService';
import { ADDON_IDS } from './addons';
import { ALL_SCOPES } from './mcp/scopes';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
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();
});
}
// Static: avatars, covers, and journey photos.
//
// Security model (audit SEC-M9): these paths are unauthenticated by
// design. All filenames are server-chosen UUID v4 (see `uuid()` in
// the multer storage config for avatars / covers / journey uploads),
// which gives each asset >122 bits of namespace entropy — not
// guessable via enumeration. An attacker would need to have already
// seen the URL (email, shared journey, etc.) to request the file.
//
// Moving these behind auth would also break:
// - Unauthenticated trip-card rendering on public share links
// - Journey public-share pages (/public/journey/:token)
// - Email-embedded avatars
//
// The `/uploads/photos/...` route below is DIFFERENT: photo URLs are
// not embedded in unauthenticated UI contexts, so that endpoint IS
// gated (session JWT with pv, or a share token scoped to the photo's
// trip).
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
// Photos require either a valid logged-in session (via JWT with the
// password_version gate) OR a share token that covers the SPECIFIC
// photo's trip. Previously any share token for any trip could request
// any photo filename by UUID — fine in practice because UUIDs are
// unguessable, but the auth model was wrong.
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
const safeName = path.basename(req.params.filename);
const filePath = path.join(__dirname, '../uploads/photos', safeName);
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
return res.status(403).send('Forbidden');
}
// existsSync here is cheap and avoids a sendFile error frame; kept
// sync because the handler is already short-lived.
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
const authHeader = req.headers.authorization;
const rawToken = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
if (!rawToken) return res.status(401).send('Authentication required');
// JWT session path (with pv check).
const user = verifyJwtAndLoadUser(rawToken);
if (user) return res.sendFile(resolved);
// Share-token path: require the token to cover the exact trip the
// photo belongs to. Expired tokens fall through to 401.
const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as { trip_id: number } | undefined;
if (!photo) return res.status(401).send('Authentication required');
const share = db.prepare(
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(rawToken) as { trip_id: number } | undefined;
if (!share || share.trip_id !== photo.trip_id) {
return res.status(401).send('Authentication required');
}
res.sendFile(resolved);
});
// Block direct access to /uploads/files
app.use('/uploads/files', (_req: Request, res: Response) => {
res.status(401).send('Authentication required');
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/auth/oidc', oidcRoutes);
app.use('/api/trips', tripsRoutes);
app.use('/api/trips/:tripId/days', daysRoutes);
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/todo', todoRoutes);
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/collab', collabRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.get('/api/reservations/upcoming', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ reservations: getUpcomingReservations(authReq.user.id) });
});
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.get('/api/health', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-store, must-revalidate')
res.json({ status: 'ok' })
});
app.use('/api/config', publicConfigRoutes);
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/admin', adminRoutes);
// Addons list endpoint
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
const providers = db.prepare(`
SELECT id, name, icon, enabled, sort_order
FROM photo_providers
WHERE enabled = 1
ORDER BY sort_order, id
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
const fields = db.prepare(`
SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
ORDER BY sort_order, id
`).all() as Array<{
provider_id: string;
field_key: string;
label: string;
input_type: string;
placeholder?: string | null;
hint?: string | null;
required: number;
secret: number;
settings_key?: string | null;
payload_key?: string | null;
sort_order: number;
}>;
const fieldsByProvider = new Map<string, typeof fields>();
for (const field of fields) {
const arr = fieldsByProvider.get(field.provider_id) || [];
arr.push(field);
fieldsByProvider.set(field.provider_id, arr);
}
res.json({
collabFeatures: getCollabFeatures(),
addons: [
...addons.map(a => ({ ...a, enabled: !!a.enabled })),
...providers.map(p => ({
id: p.id,
name: p.name,
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
placeholder: f.placeholder || '',
hint: f.hint || null,
required: !!f.required,
secret: !!f.secret,
settings_key: f.settings_key || null,
payload_key: f.payload_key || null,
sort_order: f.sort_order,
})),
})),
],
});
});
// Addon routes
app.use('/api/addons/vacay', vacayRoutes);
app.use('/api/addons/atlas', atlasRoutes);
app.use('/api/journeys', (req, res, next) => {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
next();
}, journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/airports', airportsRoutes);
// /api/weather is served by the NestJS weather module (see src/nest/weather);
// the legacy Express route was decommissioned after the migration (L1).
app.use('/api/settings', settingsRoutes);
app.use('/api/system-notices', systemNoticesRoutes);
app.use('/api/backup', backupRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api', shareRoutes);
// OAuth 2.1 — public endpoints
// Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting)
const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
next();
};
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
// Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate
app.use('/api/oauth', oauthApiRouter);
// SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB)
// is not called at createApp() time, before test tables have been created.
// mcpAuthMetadataRouter serves:
// /.well-known/oauth-authorization-server — RFC 8414 AS metadata
// /.well-known/oauth-protected-resource/mcp — RFC 9728 path-based PRM (fixes issue #959 bug 1)
let _oauthMetadata: OAuthMetadata | null = null;
let _sdkMetaRouter: express.Router | null = null;
function getOAuthMetadata(): OAuthMetadata {
if (_oauthMetadata) return _oauthMetadata;
const base = getMcpSafeUrl().replace(/\/+$/, '');
_oauthMetadata = {
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
};
return _oauthMetadata;
}
function getMetaRouter(): express.Router {
if (_sdkMetaRouter) return _sdkMetaRouter;
const metadata = getOAuthMetadata();
_sdkMetaRouter = mcpAuthMetadataRouter({
oauthMetadata: metadata,
resourceServerUrl: new URL(`${metadata.issuer}/mcp`),
scopesSupported: ALL_SCOPES as string[],
resourceName: 'TREK MCP',
});
return _sdkMetaRouter;
}
// Only invoke the SDK metadata router for /.well-known/* paths.
// Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which
// throws "Invalid URL" when APP_URL lacks a protocol — breaking all page loads.
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
getMetaRouter()(req, res, next);
});
// ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via
// /.well-known/openid-configuration. Serve the AS metadata plus the OIDC
// userinfo_endpoint so ChatGPT can fetch the authenticated user's email
// for authorization domain claiming.
app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => {
const meta = getOAuthMetadata();
res.json({
...meta,
userinfo_endpoint: `${meta.issuer}/oauth/userinfo`,
});
});
// RFC 9728 flat well-known URL — served alongside the path-based form the SDK already provides.
// Clients like ChatGPT probe /.well-known/oauth-protected-resource (no path suffix) on every
// fresh discovery. Without this, they get 404, fall back to the issuer URL as the resource
// parameter, and the authorize handler rejects them with invalid_target — showing the user
// the TREK home page instead of the consent form.
app.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const meta = getOAuthMetadata();
res.json({
resource: `${meta.issuer}/mcp`,
authorization_servers: [meta.issuer],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
// SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects
// to the SPA consent page at /oauth/consent
app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider }));
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore }));
// Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth)
// oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here
app.use('/', oauthPublicRouter);
// MCP endpoint
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
app.delete('/mcp', mcpHandler);
// Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't handle.
// Without this, the SPA catch-all serves HTML — clients probing
// /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata URL
// receive a 200 HTML response they can't parse as JSON, causing "does not implement OAuth".
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/.well-known/')) return res.status(404).json({ error: 'not_found' });
next();
});
// Helmet's COOP: same-origin isolates the consent popup from its cross-origin opener (ChatGPT etc.), making window.opener null and breaking the OAuth flow.
app.use('/oauth/consent', (_req: Request, res: Response, next: NextFunction) => {
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
next();
});
// Production static file serving
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
}));
app.get('*', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(publicPath, 'index.html'));
});
}
// Global error handler
app.use((err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => {
if (process.env.NODE_ENV === 'production') {
console.error('Unhandled error:', err.message);
} else {
console.error('Unhandled error:', err);
}
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message });
}
const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
});
return app;
}
+45
View File
@@ -0,0 +1,45 @@
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import { AppModule } from './nest/app.module';
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } from './nest/platform/platform.routes';
/**
* Builds the unified TREK NestJS application that serves the ENTIRE surface — the
* former Express app is gone. One builder is shared by the production bootstrap
* (index.ts) and the integration-test harness so the two can never drift.
*
* Composition order is load-bearing. Everything except the SPA index.html fallback
* is registered on the underlying Express instance BEFORE `app.init()`, because
* Nest's router terminates an unmatched request by throwing NotFoundException — it
* does NOT fall through to a route registered after init, so a post-init Express
* route is unreachable. The platform routes are all specific paths (/uploads/*,
* /api/health, /mcp, /.well-known/*, /oauth/{authorize,register,consent}) so they
* match their own requests and `next()` everything else through to the Nest
* controllers registered during init.
*
* 1. applyGlobalMiddleware — helmet/CSP, CORS, HSTS, forced-HTTPS, the global MFA
* policy, request logging + cookie-parser. `bodyParser: false` so Nest does its
* own parsing and the raw /mcp body reaches the MCP handler unparsed.
* 2. applyPlatformUploads — the static + guarded /uploads/* routes.
* 3. applyPlatformTransport — /api/health, the OAuth/MCP SDK + /.well-known
* metadata, the /mcp routes, the /oauth/consent COOP header.
* 4. applyPlatformStatic — the production built-client static assets (so a real
* asset request returns the file before the Nest router 404s it).
* 5. app.init() — registers every migrated /api domain (the Nest controllers).
*
* The SPA index.html fallback (unmatched GET → index.html in production) is the
* SpaFallbackFilter (APP_FILTER in AppModule); the global error envelope is the
* TrekExceptionFilter (also APP_FILTER).
*/
export async function buildApp(): Promise<INestApplication> {
const app = await NestFactory.create(AppModule, new ExpressAdapter());
const instance = app.getHttpAdapter().getInstance();
applyGlobalMiddleware(instance, { bodyParser: false });
applyPlatformUploads(instance);
applyPlatformTransport(instance);
applyPlatformStatic(instance);
await app.init();
return app;
}
+7
View File
@@ -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)) {
+43 -30
View File
@@ -542,7 +542,7 @@ function runMigrations(db: Database.Database): void {
}
},
() => {
try { db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); } catch {}
try { db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
() => {
db.exec(`
@@ -803,7 +803,14 @@ function runMigrations(db: Database.Database): void {
`);
},
() => {
try {db.exec("UPDATE addons SET enabled = 0 WHERE id = 'memories'");} catch (err) {}
try {
db.exec("UPDATE addons SET enabled = 0 WHERE id = 'memories'");
} catch (err) {
// Non-fatal: the addons table may not exist yet on very old databases.
// Disabling the legacy memories addon is best-effort, but we no longer
// swallow the error silently.
console.warn("[migrations] Non-fatal: failed to disable legacy 'memories' addon:", err);
}
},
// Migration 69: Place region cache for sub-national Atlas regions
() => {
@@ -1194,21 +1201,21 @@ function runMigrations(db: Database.Database): void {
// Migration 85: Journal — richer entry fields for magazine-style design
() => {
// Highlight tags (JSON array), visibility control, hero photo, color accent
try { db.exec('ALTER TABLE journey_entries ADD COLUMN highlight_tags TEXT'); } catch {}
try { db.exec("ALTER TABLE journey_entries ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN hero_photo_id TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN color_accent TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_name TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lat REAL'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lng REAL'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN highlight_tags TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec("ALTER TABLE journey_entries ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_entries ADD COLUMN hero_photo_id TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_entries ADD COLUMN color_accent TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_name TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lat REAL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lng REAL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
// Check-in: allow a single cover photo reference
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN photo_id TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN photo_id TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
// Photos: add caption edit timestamp for gallery ordering
try { db.exec('ALTER TABLE journey_photos ADD COLUMN width INTEGER'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN height INTEGER'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN width INTEGER'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_photos ADD COLUMN height INTEGER'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration 86: Journey multi-trip support + sharing/collaboration
() => {
@@ -1239,8 +1246,8 @@ function runMigrations(db: Database.Database): void {
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)');
// author tracking on entries and checkins
try { db.exec('ALTER TABLE journey_entries ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration 87: Journey rebuild — new schema with trip sync
() => {
@@ -1255,9 +1262,9 @@ function runMigrations(db: Database.Database): void {
if (hasOldJourneys) {
// Save existing data before dropping
try { oldJourneys = db.prepare('SELECT * FROM journeys').all(); } catch {}
try { oldEntries = db.prepare('SELECT * FROM journey_entries').all(); } catch {}
try { oldPhotos = db.prepare('SELECT * FROM journey_photos').all(); } catch {}
try { oldJourneys = db.prepare('SELECT * FROM journeys').all(); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { oldEntries = db.prepare('SELECT * FROM journey_entries').all(); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { oldPhotos = db.prepare('SELECT * FROM journey_photos').all(); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
// Drop all old journey tables
db.exec('DROP TABLE IF EXISTS journey_location_trail');
@@ -1393,7 +1400,7 @@ function runMigrations(db: Database.Database): void {
INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at)
VALUES (?, ?, ?)
`).run(Number(res.lastInsertRowid), j.trip_id, ts);
} catch {}
} catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
}
}
@@ -1450,10 +1457,10 @@ function runMigrations(db: Database.Database): void {
},
// Migration 88: Journey photos — provider support (Immich/Synology)
() => {
try { db.exec("ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'"); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN asset_id TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1'); } catch {}
try { db.exec("ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_photos ADD COLUMN asset_id TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
// file_path was NOT NULL — recreate table to make it nullable
const hasProvider = db.prepare("SELECT 1 FROM pragma_table_info('journey_photos') WHERE name = 'provider'").get();
if (hasProvider) {
@@ -1481,16 +1488,16 @@ function runMigrations(db: Database.Database): void {
ALTER TABLE journey_photos_new RENAME TO journey_photos;
CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
`);
} catch {}
} catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
}
},
// Migration 89: Journey cover image
() => {
try { db.exec('ALTER TABLE journeys ADD COLUMN cover_image TEXT'); } catch {}
try { db.exec('ALTER TABLE journeys ADD COLUMN cover_image TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration 90: Pros/Cons for journey entries
() => {
try { db.exec('ALTER TABLE journey_entries ADD COLUMN pros_cons TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN pros_cons TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration 91: Journey share tokens
() => {
@@ -1512,7 +1519,7 @@ function runMigrations(db: Database.Database): void {
},
// Migration: Vacay week_start setting (0=Sunday, 1=Monday default)
() => {
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1"); } catch {}
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration: Unified Photo Provider Abstraction Layer (#584)
// Central trek_photos registry; trip_photos + journey_photos reference via photo_id
@@ -1655,7 +1662,7 @@ function runMigrations(db: Database.Database): void {
},
// Migration 99: hide_skeletons per-user setting on journey_contributors
() => {
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration 100: Idempotency keys for offline mutation replay
() => {
@@ -1895,7 +1902,10 @@ function runMigrations(db: Database.Database): void {
const names = new Set(cols.map((c) => c.name));
if (names.has('start_day_id')) db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)');
if (names.has('end_day_id')) db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)');
} catch { /* table may not exist on very old installs */ }
} catch (err) {
// Non-fatal: day_accommodations may not exist on very old installs.
console.warn('[migrations] Non-fatal migration step failed:', err);
}
try {
// notifications schema has varied; probe before indexing.
const cols = db.prepare("PRAGMA table_info('notifications')").all() as Array<{ name: string }>;
@@ -1903,7 +1913,10 @@ function runMigrations(db: Database.Database): void {
if (names.has('target') && names.has('scope')) {
db.exec('CREATE INDEX IF NOT EXISTS idx_notifications_target_scope ON notifications(target, scope)');
}
} catch { /* notifications table may not exist on very old installs */ }
} catch (err) {
// Non-fatal: notifications table may not exist on very old installs.
console.warn('[migrations] Non-fatal migration step failed:', err);
}
},
// Migration: widen idempotency_keys primary key to (key, user_id,
// method, path). The middleware lookup was widened in the same audit
+4 -1
View File
@@ -1,6 +1,9 @@
import Database from 'better-sqlite3';
import crypto from 'crypto';
// bcrypt cost factor for the seeded admin password — kept in sync with authService.
const BCRYPT_COST = 12;
// Seeds run at startup before the DB admin panel can be used, so only env vars
// are checked here. The granular password_login/password_registration DB toggles
// are only relevant after the first user exists; at that point seeds have already
@@ -40,7 +43,7 @@ function seedAdminAccount(db: Database.Database): void {
email = 'admin@trek.local';
}
const hash = bcrypt.hashSync(password, 12);
const hash = bcrypt.hashSync(password, BCRYPT_COST);
const username = 'admin';
db.prepare('INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)').run(username, email, hash, 'admin');
+8 -42
View File
@@ -3,14 +3,8 @@ import 'dotenv/config';
import path from 'node:path';
import fs from 'node:fs';
import http from 'node:http';
import express from 'express';
import cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import { createApp } from './app';
import { AppModule } from './nest/app.module';
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
import { buildApp } from './bootstrap';
// Create upload and data directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
@@ -25,11 +19,6 @@ const tmpDir = path.join(__dirname, '../data/tmp');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
// in front of it (strangler pattern): migrated route prefixes are served by Nest,
// everything else falls through to this app via a fallback middleware.
const legacyApp = createApp();
import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
@@ -61,11 +50,7 @@ const onListen = () => {
'──────────────────────────────────────',
];
banner.forEach(l => console.log(l));
sLogInfo(
NEST_PREFIXES.length
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
: 'NestJS prefixes: none — all routes served by the legacy Express app',
);
sLogInfo('NestJS serving all routes (Express decommissioned)');
if (process.env.APP_URL) {
let parsedAppUrl: URL | null = null;
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
@@ -105,30 +90,13 @@ let server: http.Server;
let nestApp: INestApplication;
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
const NEST_PREFIXES = getNestPrefixes();
const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
async function bootstrap(): Promise<void> {
// Nest runs on its own Express instance (bodyParser off so request bodies reach
// the legacy app untouched — it has its own parsers; /mcp relies on raw body).
// Nest body parsing is safe here: the dispatcher only forwards migrated
// 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());
// (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
// only applies within migrated prefixes.
const top = express();
top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
top.use(legacyApp);
server = http.createServer(top);
// The whole surface runs on the single NestJS app now (Express decommissioned):
// global pipeline + /uploads + every /api domain + the platform/transport routes
// (/mcp, /.well-known, OAuth SDK, SPA catch-all). buildApp() owns the composition
// order; it is shared with the integration-test harness so they can't drift.
nestApp = await buildApp();
server = http.createServer(nestApp.getHttpAdapter().getInstance());
if (HOST) server.listen(PORT, HOST, onListen);
else server.listen(PORT, onListen);
}
@@ -161,5 +129,3 @@ function shutdown(signal: string): void {
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
export default legacyApp;
+1
View File
@@ -380,6 +380,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const shareLink = getJourneyShareLink(journeyId);
return ok({ shareLink });
}
+9 -9
View File
@@ -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.'}` } }],
+163
View File
@@ -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();
});
}
+27 -1
View File
@@ -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,22 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AddonsService } from './addons.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
/**
* GET /api/addons — the enabled trip add-ons + photo providers feed.
* Byte-identical to the legacy inline handler in server/src/app.ts
* (authenticate-gated, returns { collabFeatures, addons: [...] }).
*
* Distinct from the addon sub-mounts /api/addons/atlas and /api/addons/vacay
* (their own Nest modules); the strangler routes only the EXACT /api/addons here.
*/
@Controller('api/addons')
@UseGuards(JwtAuthGuard)
export class AddonsController {
constructor(private readonly addons: AddonsService) {}
@Get()
list() {
return this.addons.list();
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AddonsController } from './addons.controller';
import { AddonsService } from './addons.service';
/**
* GET /api/addons — enabled add-ons + photo providers (was an inline handler in
* server/src/app.ts). The addon sub-features (atlas, vacay) keep their own
* modules; this only serves the EXACT /api/addons listing.
*/
@Module({
controllers: [AddonsController],
providers: [AddonsService],
})
export class AddonsModule {}
+81
View File
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { db } from '../../db/database';
import type { Addon } from '../../types';
import { getCollabFeatures } from '../../services/adminService';
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
/**
* Thin wrapper around the enabled-addons + photo-provider read that the legacy
* inline `GET /api/addons` handler performed (server/src/app.ts). The SQL,
* ordering, boolean coercions and the merged photo-provider entries are
* reproduced 1:1 so the body is byte-identical for the client.
*/
@Injectable()
export class AddonsService {
list() {
const addons = db
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
.all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
const providers = db
.prepare(
`SELECT id, name, icon, enabled, sort_order
FROM photo_providers
WHERE enabled = 1
ORDER BY sort_order, id`,
)
.all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
const fields = db
.prepare(
`SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
ORDER BY sort_order, id`,
)
.all() as Array<{
provider_id: string;
field_key: string;
label: string;
input_type: string;
placeholder?: string | null;
hint?: string | null;
required: number;
secret: number;
settings_key?: string | null;
payload_key?: string | null;
sort_order: number;
}>;
const fieldsByProvider = new Map<string, typeof fields>();
for (const field of fields) {
const arr = fieldsByProvider.get(field.provider_id) || [];
arr.push(field);
fieldsByProvider.set(field.provider_id, arr);
}
return {
collabFeatures: getCollabFeatures(),
addons: [
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
...providers.map((p) => ({
id: p.id,
name: p.name,
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
placeholder: f.placeholder || '',
hint: f.hint || null,
required: !!f.required,
secret: !!f.secret,
settings_key: f.settings_key || null,
payload_key: f.payload_key || null,
sort_order: f.sort_order,
})),
})),
],
};
}
}
+339
View File
@@ -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);
}
}
}
+9
View File
@@ -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 {}
+79
View File
@@ -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;
}
}
+41 -2
View File
@@ -1,23 +1,62 @@
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 { MemoriesModule } from './memories/memories.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 { AddonsModule } from './addons/addons.module';
import { TrekExceptionFilter } from './common/trek-exception.filter';
import { SpaFallbackFilter } from './platform/spa-fallback.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, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule],
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 },
// SPA fallback: serves index.html for unmatched GETs in production (the Nest
// equivalent of the legacy Express app.get('*') catch-all). @Catch(NotFoundException)
// is more specific than TrekExceptionFilter, so Nest routes 404s here.
{ provide: APP_FILTER, useClass: SpaFallbackFilter },
// 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);
}
}
+142
View File
@@ -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 };
}
}
+10
View File
@@ -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 {}
+75
View File
@@ -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 -2
View File
@@ -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,159 @@
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) {
// Per-IP brute-force guard, parity with the legacy resetLimiter (5 / 15 min on
// a dedicated bucket) — without it reset tokens could be guessed unthrottled.
this.limit('reset', req, 5);
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 };
}
}
+267
View File
@@ -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;
}
}
+17
View File
@@ -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 {}
+61
View File
@@ -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); }
}
+26
View File
@@ -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;
}
}
+1 -1
View File
@@ -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();
}
}
+167
View File
@@ -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 };
}
}
+9
View File
@@ -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 {}
+24
View File
@@ -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; }
}
+183
View File
@@ -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 };
}
}
+10
View File
@@ -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 {}
+89
View File
@@ -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);
}
}
+300
View File
@@ -0,0 +1,300 @@
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 },
defParamCharset: 'utf8', // parity with legacy routes/collab.ts — preserve non-ASCII original filenames
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 };
}
}
}
+9
View File
@@ -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 {}
+63
View File
@@ -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();
}
}
+45 -18
View File
@@ -1,42 +1,69 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { Response } from 'express';
import { MulterError } from 'multer';
/**
* Normalises every Nest exception to TREK's legacy error envelope so migrated
* routes are byte-identical for the client:
* - 4xx -> { error: <message> } (5xx -> { error: 'Internal server error' })
* - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
* This replaces Nest's default { statusCode, message, error } body, which the
* TREK client does not expect.
* routes are byte-identical for the client. This mirrors the legacy global
* Express error handler (server/src/app.ts) exactly:
* - multer errors -> 413 (LIMIT_FILE_SIZE) / 400, body { error: <multer message> }
* - { error, code? } bodies -> passed through unchanged (auth guards, ZodValidationPipe)
* - other HttpExceptions -> { error: <message> } at the same status
* - plain errors w/ statusCode/status -> that status, { error: <message> } for 4xx
* - everything else -> 500 { error: 'Internal server error' }
*
* Without the multer + statusCode handling, file-upload rejections (multer's
* LIMIT_FILE_SIZE and the fileFilter errors that carry `statusCode = 400`) would
* collapse to Nest's `{ statusCode, message, error }` 413 body or a 500, diverging
* from the legacy `{ error: 'File too large' }` (413) and `{ error: '<reason>' }` (400).
*/
@Catch()
export class TrekExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
// 1. Raw multer errors that slipped past @nestjs/platform-express's
// transformException (it leaves codes it does not recognise untouched).
// Legacy: LIMIT_FILE_SIZE -> 413, everything else -> 400, body { error: message }.
if (exception instanceof MulterError) {
const status = exception.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
res.status(status).json({ error: exception.message });
return;
}
if (exception instanceof HttpException) {
const status = exception.getStatus();
const body = exception.getResponse();
// Already in TREK shape (e.g. guards throw { error, code }): pass through.
if (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)) {
res.status(status).json(body);
if (body && typeof body === 'object') {
const obj = body as Record<string, unknown>;
// TREK-native shape ({ error } / { error, code } from guards + the Zod
// pipe): pass through verbatim. Nest's own exceptions instead carry the
// { statusCode, message, error } trio (incl. transformException's
// PayloadTooLargeException for LIMIT_FILE_SIZE) and must be normalised.
if ('error' in obj && !('statusCode' in obj) && !('message' in obj)) {
res.status(status).json(obj);
return;
}
const raw = obj.message ?? obj.error;
const message =
status < 500 ? (Array.isArray(raw) ? raw.join(', ') : String(raw ?? 'Error')) : 'Internal server error';
res.status(status).json({ error: message });
return;
}
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
const message =
status < 500
? Array.isArray(raw)
? raw.join(', ')
: String(raw ?? 'Error')
: 'Internal server error';
const message = status < 500 ? String(body ?? 'Error') : 'Internal server error';
res.status(status).json({ error: message });
return;
}
// Unknown/unhandled error — mirror the legacy 500 behaviour.
console.error('Unhandled error:', exception);
res.status(500).json({ error: 'Internal server error' });
// 2. Plain errors carrying an explicit status (the fileFilter rejections set
// `statusCode = 400`; transformException returns them unchanged). Legacy:
// status = err.statusCode || err.status || 500; 4xx exposes err.message.
const err = exception as { statusCode?: number; status?: number; message?: unknown } | null;
const status = (err && (err.statusCode || err.status)) || 500;
if (status >= 500) console.error('Unhandled error:', exception);
const message = status < 500 ? String(err?.message ?? 'Error') : 'Internal server error';
res.status(status).json({ error: message });
}
}
@@ -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 };
}
}
+8
View File
@@ -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 };
}
}
+50
View File
@@ -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);
}
}
+100
View File
@@ -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 };
}
}
+16
View File
@@ -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 {}
+49
View File
@@ -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,58 @@
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)}"`);
}
// Serve with an explicit { root } + basename rather than an absolute path:
// under the Nest ExpressAdapter, res.sendFile(absolutePath) resolves the
// file relative to the (rewritten) req.url and fails with a spurious
// "Not Found", whereas the root-relative form streams correctly. The
// resolveFilePath guard above already pins this to the uploads dir.
res.sendFile(path.basename(resolved), { root: path.dirname(resolved) });
}
}
+225
View File
@@ -0,0 +1,225 @@
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 },
defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames
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) };
}
}
+10
View File
@@ -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 {}
+49
View File
@@ -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 };
}
}
+11
View File
@@ -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);
}
}
+198
View File
@@ -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);
}
}
}
+10
View File
@@ -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 {}
+89
View File
@@ -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,193 @@
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { User } from '../../types';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../../services/auditLog';
/**
* /api/integrations/memories/immich — Immich connection, browse/search, asset
* proxy and album linking.
*
* Byte-identical to the legacy Express router (server/src/routes/memories/immich.ts):
* `/status` and `/test` answer 200 even on connection failure (the service shapes
* `{ connected: false, ... }`); `/settings` PUT validates with a 400; the asset
* routes do the 400 invalid-id guard then the canAccessUserPhoto 403 ('Forbidden')
* before streaming or returning info; the album sync answers 200 then broadcasts.
* The legacy `canAccessTrip` import there is dead code — intentionally not ported.
*/
@Controller('api/integrations/memories/immich')
@UseGuards(JwtAuthGuard)
export class ImmichMemoriesController {
constructor(private readonly memories: MemoriesService) {}
@Get('settings')
getSettings(@CurrentUser() user: User) {
return this.memories.immichGetConnectionSettings(user.id);
}
@Put('settings')
async putSettings(
@CurrentUser() user: User,
@Body() body: { immich_url?: string; immich_api_key?: string; auto_upload?: unknown },
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const { immich_url, immich_api_key, auto_upload } = body;
const result = await this.memories.immichSaveSettings(user.id, immich_url, immich_api_key, getClientIp(req));
if (!result.success) {
res.status(400).json({ error: result.error });
return;
}
if (typeof auto_upload === 'boolean') {
this.memories.immichSetAutoUpload(user.id, auto_upload);
}
if (result.warning) {
res.json({ success: true, warning: result.warning });
return;
}
res.json({ success: true });
}
@Get('status')
async getStatus(@CurrentUser() user: User) {
return this.memories.immichGetConnectionStatus(user.id);
}
@Post('test')
@HttpCode(200)
async test(@Body() body: { immich_url?: string; immich_api_key?: string }) {
const { immich_url, immich_api_key } = body;
if (!immich_url || !immich_api_key) {
return { connected: false, error: 'URL and API key required' };
}
return this.memories.immichTestConnection(immich_url, immich_api_key);
}
@Get('browse')
async browse(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
const result = await this.memories.immichBrowseTimeline(user.id);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ buckets: result.buckets });
}
@Post('search')
@HttpCode(200)
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
const { from, to, size, page } = body as { from?: string; to?: string; size?: unknown; page?: unknown };
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.min(Number(size) || 50, 200);
const result = await this.memories.immichSearchPhotos(user.id, from, to, pageNum, pageSize);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
}
@Get('assets/:tripId/:assetId/:ownerId/info')
async assetInfo(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('assetId') assetId: string,
@Param('ownerId') ownerId: string,
@Res() res: Response,
): Promise<void> {
if (!this.memories.immichIsValidAssetId(assetId)) {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
res.status(403).json({ error: 'Forbidden' });
return;
}
const result = await this.memories.immichGetAssetInfo(user.id, assetId, Number(ownerId));
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json(result.data);
}
@Get('assets/:tripId/:assetId/:ownerId/thumbnail')
async assetThumbnail(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('assetId') assetId: string,
@Param('ownerId') ownerId: string,
@Res() res: Response,
): Promise<void> {
if (!this.memories.immichIsValidAssetId(assetId)) {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
res.status(403).json({ error: 'Forbidden' });
return;
}
await this.memories.immichStreamAsset(res, user.id, assetId, 'thumbnail', Number(ownerId));
}
@Get('assets/:tripId/:assetId/:ownerId/original')
async assetOriginal(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('assetId') assetId: string,
@Param('ownerId') ownerId: string,
@Res() res: Response,
): Promise<void> {
if (!this.memories.immichIsValidAssetId(assetId)) {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
res.status(403).json({ error: 'Forbidden' });
return;
}
await this.memories.immichStreamAsset(res, user.id, assetId, 'original', Number(ownerId));
}
@Get('albums')
async albums(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
const result = await this.memories.immichListAlbums(user.id);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ albums: result.albums });
}
@Get('albums/:albumId/photos')
async albumPhotos(@CurrentUser() user: User, @Param('albumId') albumId: string, @Res() res: Response): Promise<void> {
const result = await this.memories.immichGetAlbumPhotos(user.id, albumId);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ assets: result.assets });
}
@Post('trips/:tripId/album-links/:linkId/sync')
@HttpCode(200)
async sync(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Headers('x-socket-id') sid: string,
@Res() res: Response,
): Promise<void> {
const result = await this.memories.immichSyncAlbumAssets(tripId, linkId, user.id, sid);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ success: true, added: result.added, total: result.total });
if (result.added! > 0) {
this.memories.broadcast(tripId, 'memories:updated', { userId: user.id }, sid);
}
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { MemoriesService } from './memories.service';
import { UnifiedMemoriesController } from './unified.controller';
import { ImmichMemoriesController } from './immich.controller';
import { SynologyMemoriesController } from './synology.controller';
/**
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
*
* Ports the legacy Express router (routes/memories/unified.ts, which composes
* immich.ts + synology.ts) to Nest, reusing services/memories/* unchanged. No
* module-level addon gate — enablement is per-provider-row inside the services,
* exactly as the legacy mount had it.
*/
@Module({
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
providers: [MemoriesService],
})
export class MemoriesModule {}
@@ -0,0 +1,183 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import {
listTripPhotos,
listTripAlbumLinks,
createTripAlbumLink,
removeAlbumLink,
addTripPhotos,
removeTripPhoto,
setTripPhotoSharing,
} from '../../services/memories/unifiedService';
import {
getConnectionSettings,
saveImmichSettings,
setImmichAutoUpload,
testConnection,
getConnectionStatus,
browseTimeline,
searchPhotos,
streamImmichAsset,
listAlbums,
getAlbumPhotos,
syncAlbumAssets,
getAssetInfo,
isValidAssetId,
} from '../../services/memories/immichService';
import {
getSynologySettings,
updateSynologySettings,
getSynologyStatus,
testSynologyConnection,
listSynologyAlbums,
getSynologyAlbumPhotos,
syncSynologyAlbumLink,
searchSynologyPhotos,
getSynologyAssetInfo,
streamSynologyAsset,
} from '../../services/memories/synologyService';
import { canAccessUserPhoto } from '../../services/memories/helpersService';
import type { Selection } from '../../services/memories/helpersService';
import { broadcast } from '../../websocket';
/**
* Thin Nest wrapper around the existing memories (photo-providers) services.
* Every method delegates to the legacy `services/memories/*` code unchanged so
* the provider logic, the per-provider access checks and the streaming helpers
* behave byte-identically to the legacy Express routers. No new business logic
* lives here.
*/
@Injectable()
export class MemoriesService {
// ── Access check (reused by both provider asset routes) ──────────────────
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
return canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId?: string): void {
broadcast(tripId, event, payload, socketId);
}
// ── Unified ──────────────────────────────────────────────────────────────
listTripPhotos(tripId: string, userId: number) {
return listTripPhotos(tripId, userId);
}
addTripPhotos(tripId: string, userId: number, shared: boolean, selections: Selection[], sid: string) {
return addTripPhotos(tripId, userId, shared, selections, sid);
}
setTripPhotoSharing(tripId: string, userId: number, photoId: number, shared: boolean) {
return setTripPhotoSharing(tripId, userId, photoId, shared);
}
removeTripPhoto(tripId: string, userId: number, photoId: number) {
return removeTripPhoto(tripId, userId, photoId);
}
listTripAlbumLinks(tripId: string, userId: number) {
return listTripAlbumLinks(tripId, userId);
}
createTripAlbumLink(tripId: string, userId: number, provider: unknown, albumId: unknown, albumName: unknown, passphrase?: string) {
return createTripAlbumLink(tripId, userId, provider, albumId, albumName, passphrase);
}
removeAlbumLink(tripId: string, linkId: string, userId: number) {
return removeAlbumLink(tripId, linkId, userId);
}
// ── Immich ─────────────────────────────────────────────────────────────────
immichGetConnectionSettings(userId: number) {
return getConnectionSettings(userId);
}
immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null) {
return saveImmichSettings(userId, immichUrl, immichApiKey, clientIp);
}
immichSetAutoUpload(userId: number, enabled: boolean): void {
setImmichAutoUpload(userId, enabled);
}
immichGetConnectionStatus(userId: number) {
return getConnectionStatus(userId);
}
immichTestConnection(immichUrl: string, immichApiKey: string) {
return testConnection(immichUrl, immichApiKey);
}
immichBrowseTimeline(userId: number) {
return browseTimeline(userId);
}
immichSearchPhotos(userId: number, from: string | undefined, to: string | undefined, page: number, size: number) {
return searchPhotos(userId, from, to, page, size);
}
immichIsValidAssetId(assetId: string): boolean {
return isValidAssetId(assetId);
}
immichGetAssetInfo(userId: number, assetId: string, ownerId: number) {
return getAssetInfo(userId, assetId, ownerId);
}
immichStreamAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) {
return streamImmichAsset(res, userId, assetId, kind, ownerId);
}
immichListAlbums(userId: number) {
return listAlbums(userId);
}
immichGetAlbumPhotos(userId: number, albumId: string) {
return getAlbumPhotos(userId, albumId);
}
immichSyncAlbumAssets(tripId: string, linkId: string, userId: number, sid: string) {
return syncAlbumAssets(tripId, linkId, userId, sid);
}
// ── Synology ────────────────────────────────────────────────────────────────
synologyGetSettings(userId: number) {
return getSynologySettings(userId);
}
synologyUpdateSettings(userId: number, url: string, username: string, password: string, skipSsl: boolean) {
return updateSynologySettings(userId, url, username, password, skipSsl);
}
synologyGetStatus(userId: number) {
return getSynologyStatus(userId);
}
synologyTestConnection(userId: number, url: string, username: string, password: string, otp: string, skipSsl: boolean) {
return testSynologyConnection(userId, url, username, password, otp, skipSsl);
}
synologyListAlbums(userId: number) {
return listSynologyAlbums(userId);
}
synologyGetAlbumPhotos(userId: number, albumId: string, passphrase?: string) {
return getSynologyAlbumPhotos(userId, albumId, passphrase);
}
synologySyncAlbumLink(userId: number, tripId: string, linkId: string, sid: string) {
return syncSynologyAlbumLink(userId, tripId, linkId, sid);
}
synologySearchPhotos(userId: number, from: string | undefined, to: string | undefined, offset: number, limit: number) {
return searchSynologyPhotos(userId, from, to, offset, limit);
}
synologyGetAssetInfo(userId: number, photoId: string, ownerId: number, passphrase?: string) {
return getSynologyAssetInfo(userId, photoId, ownerId, passphrase);
}
synologyStreamAsset(res: Response, userId: number, ownerId: number, photoId: string, kind: 'thumbnail' | 'original', size: string, passphrase?: string) {
return streamSynologyAsset(res, userId, ownerId, photoId, kind, size, passphrase);
}
}
@@ -0,0 +1,170 @@
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import type { User } from '../../types';
import type { ServiceResult } from '../../services/memories/helpersService';
import { fail, success } from '../../services/memories/helpersService';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
function _parseStringBodyField(value: unknown): string {
return String(value ?? '').trim();
}
function _parseNumberBodyField(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
/**
* /api/integrations/memories/synologyphotos — Synology Photos connection,
* search, albums and asset proxy.
*
* Byte-identical to the legacy Express router (server/src/routes/memories/synology.ts):
* every response goes through the service `ServiceResult` envelope (success →
* `res.json(data)` at 200, error → status + `{ error }`); `/status` and `/test`
* always answer 200 (the service shapes `{ connected: false, error }` on
* failure); the asset routes use the distinct 403 string "You don't have access
* to this photo"; `/info` is declared before the catch-all `/:kind` so the
* literal route wins as Express ordered it; lenient hand-rolled coercion is kept.
*/
@Controller('api/integrations/memories/synologyphotos')
@UseGuards(JwtAuthGuard)
export class SynologyMemoriesController {
constructor(private readonly memories: MemoriesService) {}
private handle<T>(res: Response, result: ServiceResult<T>): void {
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
} else {
res.json(result.data);
}
}
@Get('settings')
async getSettings(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
this.handle(res, await this.memories.synologyGetSettings(user.id));
}
@Put('settings')
async putSettings(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username) {
this.handle(res, fail('URL and username are required', 400));
} else {
this.handle(res, await this.memories.synologyUpdateSettings(user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
}
}
@Get('status')
async getStatus(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
this.handle(res, await this.memories.synologyGetStatus(user.id));
}
@Post('test')
@HttpCode(200)
async test(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username || !synology_password) {
const missingFields: string[] = [];
if (!synology_url) missingFields.push('URL');
if (!synology_username) missingFields.push('Username');
if (!synology_password) missingFields.push('Password');
this.handle(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
} else {
this.handle(res, await this.memories.synologyTestConnection(user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
}
}
@Get('albums')
async albums(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
this.handle(res, await this.memories.synologyListAlbums(user.id));
}
@Get('albums/:albumId/photos')
async albumPhotos(@CurrentUser() user: User, @Param('albumId') albumId: string, @Query('passphrase') passphraseRaw: string | undefined, @Res() res: Response): Promise<void> {
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
this.handle(res, await this.memories.synologyGetAlbumPhotos(user.id, albumId, passphrase));
}
@Post('trips/:tripId/album-links/:linkId/sync')
@HttpCode(200)
async sync(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Headers('x-socket-id') sid: string,
@Res() res: Response,
): Promise<void> {
this.handle(res, await this.memories.synologySyncAlbumLink(user.id, tripId, linkId, sid));
}
@Post('search')
@HttpCode(200)
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to);
let offset = _parseNumberBodyField(body.offset, 0);
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if (size > 0) limit = size;
if (page > 0) offset = page * limit;
this.handle(res, await this.memories.synologySearchPhotos(user.id, from || undefined, to || undefined, offset, limit));
}
@Get('assets/:tripId/:photoId/:ownerId/info')
async assetInfo(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('photoId') photoId: string,
@Param('ownerId') ownerId: string,
@Query('passphrase') passphraseRaw: string | undefined,
@Res() res: Response,
): Promise<void> {
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
this.handle(res, fail("You don't have access to this photo", 403));
} else {
this.handle(res, await this.memories.synologyGetAssetInfo(user.id, photoId, Number(ownerId), passphrase));
}
}
@Get('assets/:tripId/:photoId/:ownerId/:kind')
async asset(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('photoId') photoId: string,
@Param('ownerId') ownerId: string,
@Param('kind') kind: string,
@Query('size') sizeRaw: string | undefined,
@Query('passphrase') passphraseRaw: string | undefined,
@Res() res: Response,
): Promise<void> {
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
const rawSize = String(sizeRaw ?? 'sm');
const size = (VALID_SIZES as readonly string[]).includes(rawSize) ? rawSize : 'sm';
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
if (kind !== 'thumbnail' && kind !== 'original') {
this.handle(res, fail('Invalid asset kind', 400));
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
this.handle(res, fail("You don't have access to this photo", 403));
} else {
await this.memories.synologyStreamAsset(res, user.id, Number(ownerId), photoId, kind, String(size), passphrase);
}
}
}
@@ -0,0 +1,123 @@
import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Post, Put, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import type { User } from '../../types';
import type { Selection } from '../../services/memories/helpersService';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
/**
* /api/integrations/memories/unified — provider-agnostic trip photo + album-link
* management.
*
* Byte-identical to the legacy Express router (server/src/routes/memories/unified.ts):
* bare `authenticate` (JwtAuthGuard), success bodies on 200, and the per-result
* error envelope `{ error }` at `result.error.status` reused from the unified
* service. Lenient hand-rolled body coercion is preserved — no Zod here.
*/
@Controller('api/integrations/memories/unified')
@UseGuards(JwtAuthGuard)
export class UnifiedMemoriesController {
constructor(private readonly memories: MemoriesService) {}
@Get('trips/:tripId/photos')
listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
const result = this.memories.listTripPhotos(tripId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ photos: result.data });
}
@Post('trips/:tripId/photos')
@HttpCode(200)
async addPhotos(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Headers('x-socket-id') sid: string,
@Res() res: Response,
): Promise<void> {
const selections: Selection[] = Array.isArray(body?.selections) ? (body.selections as Selection[]) : [];
const shared = body?.shared === undefined ? true : !!body?.shared;
const result = await this.memories.addTripPhotos(tripId, user.id, shared, selections, sid);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true, added: result.data.added });
}
@Put('trips/:tripId/photos/sharing')
async setSharing(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Res() res: Response,
): Promise<void> {
const result = await this.memories.setTripPhotoSharing(tripId, user.id, Number(body?.photo_id), body?.shared as boolean);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true });
}
@Delete('trips/:tripId/photos')
async removePhoto(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Res() res: Response,
): Promise<void> {
const result = this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id));
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true });
}
@Get('trips/:tripId/album-links')
listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
const result = this.memories.listTripAlbumLinks(tripId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ links: result.data });
}
@Post('trips/:tripId/album-links')
@HttpCode(200)
createAlbumLink(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Res() res: Response,
): void {
const passphrase = body?.passphrase ? String(body.passphrase) : undefined;
const result = this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true });
}
@Delete('trips/:tripId/album-links/:linkId')
removeAlbumLink(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Res() res: Response,
): void {
const result = this.memories.removeAlbumLink(tripId, linkId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true });
}
}
@@ -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({});
}
}
+17
View File
@@ -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 {}
+36
View File
@@ -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); }
}
+142
View File
@@ -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 });
}
}
+9
View File
@@ -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 {}
+31
View File
@@ -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 };
}
}
+10
View File
@@ -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 {}
+104
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+24
View File
@@ -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);
}
}
+282
View File
@@ -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 };
}
}
+10
View File
@@ -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 {}
+79
View File
@@ -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 */ } }
}
+267
View File
@@ -0,0 +1,267 @@
import express, { Request, Response, NextFunction } from 'express';
import path from 'node:path';
import fs from 'node:fs';
import { verifyJwtAndLoadUser } from '../../middleware/auth';
import { db } from '../../db/database';
import { mcpHandler } from '../../mcp';
import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { ALL_SCOPES } from '../../mcp/scopes';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
import { getMcpSafeUrl } from '../../services/notifications';
// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be
// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8).
//
// IMPORTANT — path resolution: the original blocks lived in src/app.ts, where __dirname
// resolves to the directory of app.js (one level above the uploads/public anchor), so they
// used '../uploads/...' and '../public'. This file lives three levels deeper
// (src/nest/platform/), so __dirname is three levels deeper too. The relative prefixes are
// therefore '../../../uploads/...' and '../../../public' — which resolve to the EXACT same
// absolute paths as before. This is the only intentional change; everything else is byte-for-byte
// identical. (rootDir/outDir preserve the tree, so the offset holds in both source/test and
// compiled/dist execution — matching the other nest controllers that use '../../../uploads/...'.)
const UPLOADS_DIR = path.join(__dirname, '../../../uploads');
export const PUBLIC_DIR = path.join(__dirname, '../../../public');
/**
* Static + guarded /uploads/* routes. Must be applied BEFORE the API route mounts
* (identical to its original position near the top of createApp).
*/
export function applyPlatformUploads(app: express.Application): void {
// Static: avatars, covers, and journey photos.
//
// Security model (audit SEC-M9): these paths are unauthenticated by
// design. All filenames are server-chosen UUID v4 (see `uuid()` in
// the multer storage config for avatars / covers / journey uploads),
// which gives each asset >122 bits of namespace entropy — not
// guessable via enumeration. An attacker would need to have already
// seen the URL (email, shared journey, etc.) to request the file.
//
// Moving these behind auth would also break:
// - Unauthenticated trip-card rendering on public share links
// - Journey public-share pages (/public/journey/:token)
// - Email-embedded avatars
//
// The `/uploads/photos/...` route below is DIFFERENT: photo URLs are
// not embedded in unauthenticated UI contexts, so that endpoint IS
// gated (session JWT with pv, or a share token scoped to the photo's
// trip).
app.use('/uploads/avatars', express.static(path.join(UPLOADS_DIR, 'avatars')));
app.use('/uploads/covers', express.static(path.join(UPLOADS_DIR, 'covers')));
app.use('/uploads/journey', express.static(path.join(UPLOADS_DIR, 'journey')));
// Photos require either a valid logged-in session (via JWT with the
// password_version gate) OR a share token that covers the SPECIFIC
// photo's trip. Previously any share token for any trip could request
// any photo filename by UUID — fine in practice because UUIDs are
// unguessable, but the auth model was wrong.
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
const safeName = path.basename(req.params.filename);
const filePath = path.join(UPLOADS_DIR, 'photos', safeName);
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(UPLOADS_DIR, 'photos'))) {
return res.status(403).send('Forbidden');
}
// existsSync here is cheap and avoids a sendFile error frame; kept
// sync because the handler is already short-lived.
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
const authHeader = req.headers.authorization;
const rawToken = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
if (!rawToken) return res.status(401).send('Authentication required');
// JWT session path (with pv check).
const user = verifyJwtAndLoadUser(rawToken);
if (user) return res.sendFile(resolved);
// Share-token path: require the token to cover the exact trip the
// photo belongs to. Expired tokens fall through to 401.
const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as { trip_id: number } | undefined;
if (!photo) return res.status(401).send('Authentication required');
const share = db.prepare(
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(rawToken) as { trip_id: number } | undefined;
if (!share || share.trip_id !== photo.trip_id) {
return res.status(401).send('Authentication required');
}
res.sendFile(resolved);
});
// Block direct access to /uploads/files
app.use('/uploads/files', (_req: Request, res: Response) => {
res.status(401).send('Authentication required');
});
}
/**
* Legacy /api/health handler, the OAuth/MCP SDK + transport wiring (well-known metadata,
* authorize/register SDK handlers, the COOP header, the /mcp routes), and the production
* SPA static + catch-all. Must be applied AFTER the API route mounts and BEFORE the global
* error handler (identical to its original position near the bottom of createApp).
*
* Note: the SDK metadata closures (getOAuthMetadata/getMetaRouter) and their lazy-init
* cache are kept module-local PER CALL so each app instance gets its own lazy state — the
* same as when they were function-local inside createApp.
*/
export function applyPlatformTransport(app: express.Application): void {
app.get('/api/health', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-store, must-revalidate')
res.json({ status: 'ok' })
});
// OAuth 2.1 — public endpoints
// Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting)
const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
next();
};
// SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB)
// is not called at createApp() time, before test tables have been created.
// mcpAuthMetadataRouter serves:
// /.well-known/oauth-authorization-server — RFC 8414 AS metadata
// /.well-known/oauth-protected-resource/mcp — RFC 9728 path-based PRM (fixes issue #959 bug 1)
let _oauthMetadata: OAuthMetadata | null = null;
let _sdkMetaRouter: express.Router | null = null;
function getOAuthMetadata(): OAuthMetadata {
if (_oauthMetadata) return _oauthMetadata;
const base = getMcpSafeUrl().replace(/\/+$/, '');
_oauthMetadata = {
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
};
return _oauthMetadata;
}
function getMetaRouter(): express.Router {
if (_sdkMetaRouter) return _sdkMetaRouter;
const metadata = getOAuthMetadata();
_sdkMetaRouter = mcpAuthMetadataRouter({
oauthMetadata: metadata,
resourceServerUrl: new URL(`${metadata.issuer}/mcp`),
scopesSupported: ALL_SCOPES as string[],
resourceName: 'TREK MCP',
});
return _sdkMetaRouter;
}
// Only invoke the SDK metadata router for /.well-known/* paths.
// Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which
// throws "Invalid URL" when APP_URL lacks a protocol — breaking all page loads.
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
getMetaRouter()(req, res, next);
});
// ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via
// /.well-known/openid-configuration. Serve the AS metadata plus the OIDC
// userinfo_endpoint so ChatGPT can fetch the authenticated user's email
// for authorization domain claiming.
app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => {
const meta = getOAuthMetadata();
res.json({
...meta,
userinfo_endpoint: `${meta.issuer}/oauth/userinfo`,
});
});
// RFC 9728 flat well-known URL — served alongside the path-based form the SDK already provides.
// Clients like ChatGPT probe /.well-known/oauth-protected-resource (no path suffix) on every
// fresh discovery. Without this, they get 404, fall back to the issuer URL as the resource
// parameter, and the authorize handler rejects them with invalid_target — showing the user
// the TREK home page instead of the consent form.
app.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const meta = getOAuthMetadata();
res.json({
resource: `${meta.issuer}/mcp`,
authorization_servers: [meta.issuer],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
// SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects
// to the SPA consent page at /oauth/consent
app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider }));
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore }));
// MCP endpoint
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
app.delete('/mcp', mcpHandler);
// Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't handle.
// Without this, the SPA catch-all serves HTML — clients probing
// /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata URL
// receive a 200 HTML response they can't parse as JSON, causing "does not implement OAuth".
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/.well-known/')) return res.status(404).json({ error: 'not_found' });
next();
});
// Helmet's COOP: same-origin isolates the consent popup from its cross-origin opener (ChatGPT etc.), making window.opener null and breaking the OAuth flow.
app.use('/oauth/consent', (_req: Request, res: Response, next: NextFunction) => {
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
next();
});
}
/**
* Production SPA serving: the built client static assets + the index.html catch-all
* for client-side routes. This is the LEGACY (plain Express 4) form — a real
* `app.get(catch-all)` registered as the terminal handler. The NestJS bootstrap can
* NOT use this (its router terminates unmatched requests with a 404 before any
* post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves
* the SPA via the SpaFallbackFilter instead. Both produce the identical result:
* unmatched GET → index.html in production.
*/
export function applyPlatformSpa(app: express.Application): void {
applyPlatformStatic(app);
if (process.env.NODE_ENV !== 'production') return;
// /.*/ rather than '*' so the helper is Express-4 and Express-5 safe.
app.get(/.*/, (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
}
/**
* Production static serving of the built client (JS/CSS/assets). Split out from
* applyPlatformSpa because the NestJS bootstrap needs the static files served
* BEFORE its router (so a real asset request returns the file, not the SPA
* index.html), while the index.html catch-all is handled separately (legacy:
* app.get catch-all; Nest: SpaFallbackFilter). No-op outside production.
*/
export function applyPlatformStatic(app: express.Application): void {
if (process.env.NODE_ENV !== 'production') return;
app.use(
express.static(PUBLIC_DIR, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
}),
);
}
@@ -0,0 +1,37 @@
import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common';
import type { Request, Response } from 'express';
import path from 'node:path';
import { PUBLIC_DIR } from './platform.routes';
/**
* Serves the built SPA (index.html) for any request the NestJS router did not
* match — the production single-page-app fallback. This replaces the legacy
* Express `app.get('*')` catch-all, which cannot run on the Nest instance: Nest's
* router terminates an unmatched request by throwing NotFoundException (it never
* falls through to a post-init Express route), so the SPA fallback has to live
* inside the Nest pipeline as a NotFound filter instead.
*
* Behaviour matches the legacy catch-all exactly: in production, an unmatched GET
* returns index.html; everything else (non-GET, or dev where there is no built
* client) keeps the standard TREK `{ error }` 404 envelope. The `@Catch(NotFoundException)`
* is more specific than the global TrekExceptionFilter, so Nest routes 404s here
* while every other error still flows through TrekExceptionFilter.
*/
@Catch(NotFoundException)
export class SpaFallbackFilter implements ExceptionFilter {
catch(exception: NotFoundException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const req = ctx.getRequest<Request>();
const res = ctx.getResponse<Response>();
if (process.env.NODE_ENV === 'production' && req.method === 'GET') {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
return;
}
// Non-production, or a non-GET miss: keep the standard TREK 404 envelope
// (identical to what TrekExceptionFilter produces for a NotFoundException).
res.status(404).json({ error: exception.message || 'Not Found' });
}
}
@@ -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,17 @@
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';
import { UpcomingReservationsController } from './upcoming-reservations.controller';
/**
* Reservations + accommodations domain (S5 — Phase 2 trip sub-domain).
* Mounts: /api/trips/:tripId/reservations, /accommodations, and the cross-trip
* /api/reservations/upcoming dashboard feed.
*/
@Module({
controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController],
providers: [ReservationsService, AccommodationsService],
})
export class ReservationsModule {}
@@ -0,0 +1,120 @@
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);
}
// Cross-trip "upcoming reservations" feed (dashboard widget). Reuses the legacy
// query unchanged; the default limit (6) matches the legacy inline handler.
listUpcoming(userId: number) {
return svc.getUpcomingReservations(userId);
}
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,24 @@
import { Controller, Get, 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';
/**
* GET /api/reservations/upcoming — the cross-trip "upcoming reservations" feed
* (dashboard widget). Byte-identical to the legacy inline handler in
* server/src/app.ts (authenticate, returns { reservations: [...] }, limit 6).
*
* Separate from the trip-scoped ReservationsController
* (/api/trips/:tripId/reservations) because the base path differs.
*/
@Controller('api/reservations')
@UseGuards(JwtAuthGuard)
export class UpcomingReservationsController {
constructor(private readonly reservations: ReservationsService) {}
@Get('upcoming')
upcoming(@CurrentUser() user: User) {
return { reservations: this.reservations.listUpcoming(user.id) };
}
}

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