From 72c3edda1a67c2ca417e5fdfda8ef3ea75e42973 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 26 Jul 2026 21:24:57 +0200 Subject: [PATCH] chore: move share backend to proper DI --- migrate.md | 2 +- server/src/mcp/tools/trips.ts | 2 +- server/src/nest/README.md | 14 +- server/src/nest/plugins/DI-MIGRATION.md | 7 +- server/src/nest/share/share.bridge.ts | 31 ++ server/src/nest/share/share.service.ts | 294 +++++++++++- server/src/services/shareService.ts | 282 ----------- server/tests/e2e/share.e2e.test.ts | 123 +++-- server/tests/unit/nest/share.service.test.ts | 476 ++++++++++++++++--- 9 files changed, 833 insertions(+), 398 deletions(-) create mode 100644 server/src/nest/share/share.bridge.ts delete mode 100644 server/src/services/shareService.ts diff --git a/migrate.md b/migrate.md index 824a34c67..6561cf455 100644 --- a/migrate.md +++ b/migrate.md @@ -18,7 +18,7 @@ Wave 2 — cross-cutting leverage (parallel to wave 3, high payoff) Wave 3 — domain services with existing modules, low fan-in -7. dayNoteService (done 2026-07), tripInviteService (done 2026-07), assignmentService (done 2026-07), shareService, settingsService, fileService, collabService, packingService (done 2026-07) — each maps 1:1 to a Nest module that's already a thin delegate; none is imported by more than one +7. dayNoteService (done 2026-07), tripInviteService (done 2026-07), assignmentService (done 2026-07), shareService (done 2026-07), settingsService, fileService, collabService, packingService (done 2026-07) — each maps 1:1 to a Nest module that's already a thin delegate; none is imported by more than one other legacy service. Wave 4 — the coupled cluster (order matters here) diff --git a/server/src/mcp/tools/trips.ts b/server/src/mcp/tools/trips.ts index 19f5a6c2b..2a80d589c 100644 --- a/server/src/mcp/tools/trips.ts +++ b/server/src/mcp/tools/trips.ts @@ -11,7 +11,7 @@ import { } from '../../services/tripService'; import { createOrUpdateShareLink, getShareLink, deleteShareLink, -} from '../../services/shareService'; +} from '../../nest/share/share.bridge'; import { isAddonEnabled, getCollabFeatures } from '../../services/adminService'; import { ADDON_IDS } from '../../addons'; import { countMessages, listPolls } from '../../services/collabService'; diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 8d4e420e4..423ce2668 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -27,8 +27,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express. categories, tags, notifications, atlas. - **Phase 2 (trip sub-domains):** vacay (addon), packing, todo. - **DI-native services (legacy `src/services/*` deleted):** tags, categories, - todo, packing, day-notes, trip-invite, assignments — see the migration recipe - below. + todo, packing, day-notes, trip-invite, assignments, share — see the migration + recipe below. ## Cross-cutting Foundation pieces @@ -110,9 +110,13 @@ surface, no plugin-host import and no bridge — the SQL folded straight into `trip-invite.service.ts`); assignments followed (a 7-tool `assignments.mcp.ts`, the plugin-host swap, and a bridge kept only for the two legacy registrars — places and reservations — that borrow its existence checks; the batch loaders -stay in `services/queryHelpers.ts`, shared with the unmigrated day/share/place -services). Repeat these steps per -service (next up: shareService). This is a **pure relocation** — byte-identical +stay in `services/queryHelpers.ts`, shared with the unmigrated day/place +services); share followed (never imported by the plugin host, and its three MCP +tools stay in the legacy trips registrar — their `trips:share` scope gate has +no declarative `access: { group, mode }` equivalent — so the port is the SQL +fold plus a 3-export `share.bridge.ts` for `mcp/tools/trips.ts`). Repeat these +steps per +service (next up: settingsService). This is a **pure relocation** — byte-identical SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it injects domain services via `PluginHostDepsFactory`, so a migrated domain adds diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md index f59e4d2bc..d21f5da94 100644 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ b/server/src/nest/plugins/DI-MIGRATION.md @@ -100,9 +100,10 @@ import for one injected service (+ module export/import) — **no more bridge files for the plugin host, ever**. The factory's import list shrinks monotonically as the recipe in `src/nest/README.md` proceeds (packing and day-notes swapped in 2026-07; trip-invite migrated without touching the -factory — it was never imported here; assignments swapped in 2026-07; next -factory swap: whichever Wave-3 domain the factory imports migrates next — -shareService/fileService/collabService are all imported here). +factory — it was never imported here; assignments swapped in 2026-07; share +migrated 2026-07 without touching the factory — it was never imported here +either; next factory swap: whichever Wave-3 domain the factory imports migrates +next — fileService/collabService are both imported here). ### Test impact (as landed) diff --git a/server/src/nest/share/share.bridge.ts b/server/src/nest/share/share.bridge.ts new file mode 100644 index 000000000..ba6f6c524 --- /dev/null +++ b/server/src/nest/share/share.bridge.ts @@ -0,0 +1,31 @@ +import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; +import { ShareService } from './share.service'; +import type { SharePermissions } from './share.service'; + +/** + * Non-Nest entry point for the share domain — for code running OUTSIDE the + * Nest container (the legacy share-link tools in src/mcp/tools/trips.ts; those + * stay in the trips registrar because their `trips:share` scope gate has no + * declarative `access: { group, mode }` equivalent yet). Exports only the + * legacy services/shareService names still consumed outside the container, + * 1:1, so repointing a consumer is an import-path-only diff. Inside the + * container, inject ShareService instead. + * Delete this file when the legacy MCP trips registrar migrates. + * + * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto + * the shared better-sqlite3 singleton. + */ +const share = new ShareService(new DatabaseService(db)); + +export function createOrUpdateShareLink(tripId: string, createdBy: number, permissions: SharePermissions) { + return share.createOrUpdate(tripId, createdBy, permissions); +} + +export function getShareLink(tripId: string) { + return share.get(tripId); +} + +export function deleteShareLink(tripId: string) { + return share.remove(tripId); +} diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts index 1624ddaa1..04f275988 100644 --- a/server/src/nest/share/share.service.ts +++ b/server/src/nest/share/share.service.ts @@ -1,15 +1,54 @@ import { Injectable } from '@nestjs/common'; +import crypto from 'crypto'; import { DatabaseService } from '../database/database.service'; import type { TripAccess } from '../database/database.service'; import { checkPermission } from '../../services/permissions'; +import { loadTagsByPlaceIds } from '../../services/queryHelpers'; +import { serveFilePath } from '../../services/placePhotoCache'; +import { getUserSettings } from '../../services/settingsService'; import type { User } from '../../types'; -import * as svc from '../../services/shareService'; type Trip = TripAccess; +const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/'; + /** - * Thin Nest wrapper around the existing share service. Trip access, the - * 'share_manage' permission and the token SQL reuse the legacy code unchanged. + * Place photo proxy URLs (`/api/maps/place-photo//bytes`) are served by the + * JWT-guarded MapsController, so they 401 for an unauthenticated shared-trip + * viewer. Rewrite them to the public, token-scoped equivalent + * (`/api/shared//place-photo//bytes`) so thumbnails load in a shared + * link. A simple prefix swap keeps the already-encoded placeId segment intact, so + * the URL round-trips. Non-proxy URLs (data:, /uploads/, null) pass through. + */ +function rewritePlacePhotoUrl(url: string | null | undefined, token: string): string | null { + if (typeof url === 'string' && url.startsWith(PLACE_PHOTO_PROXY_PREFIX)) { + return `/api/shared/${token}/place-photo/${url.slice(PLACE_PHOTO_PROXY_PREFIX.length)}`; + } + return url ?? null; +} + +export interface SharePermissions { + share_map?: boolean; + share_bookings?: boolean; + share_packing?: boolean; + share_budget?: boolean; + share_collab?: boolean; +} + +export interface ShareTokenInfo { + token: string; + created_at: string; + share_map: boolean; + share_bookings: boolean; + share_packing: boolean; + share_budget: boolean; + share_collab: boolean; +} + +/** + * Public share links — the legacy shareService SQL folded in over the injected + * DatabaseService (byte-identical statements and coercions). Trip access and + * the 'share_manage' permission gate create/delete; the shared read is public. */ @Injectable() export class ShareService { @@ -23,11 +62,248 @@ export class ShareService { return checkPermission('share_manage', user.role, trip.user_id, user.id, trip.user_id !== user.id); } - createOrUpdate(tripId: string, userId: number, permissions: Parameters[2]) { - return svc.createOrUpdateShareLink(tripId, userId, permissions); + /** + * Creates a new share link or updates the permissions on an existing one. + * Returns an object with the token string and whether it was newly created. + */ + createOrUpdate(tripId: string, userId: number, permissions: SharePermissions): { token: string; created: boolean } { + const { + share_map = true, + share_bookings = true, + share_packing = false, + share_budget = false, + share_collab = false, + } = permissions; + + const existing = this.dbs.get<{ token: string }>('SELECT token FROM share_tokens WHERE trip_id = ?', tripId); + if (existing) { + this.dbs.run( + 'UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?', + share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId, + ); + return { token: existing.token, created: false }; + } + + // New share links default to a 90-day TTL. Existing tokens that were + // created before the expires_at migration keep NULL here and remain + // valid indefinitely until the owner rotates them; that preserves + // behaviour for anyone who's already sharing a link. + const token = crypto.randomBytes(24).toString('base64url'); + const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); + this.dbs.run( + 'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + tripId, token, userId, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt, + ); + return { token, created: true }; + } + + /** + * Returns share token info for a trip, or null if no share link exists. + */ + get(tripId: string): ShareTokenInfo | null { + const row = this.dbs.get('SELECT * FROM share_tokens WHERE trip_id = ?', tripId); + if (!row) return null; + return { + token: row.token, + created_at: row.created_at, + share_map: !!row.share_map, + share_bookings: !!row.share_bookings, + share_packing: !!row.share_packing, + share_budget: !!row.share_budget, + share_collab: !!row.share_collab, + }; + } + + /** + * Deletes the share token for a trip. + */ + remove(tripId: string): void { + this.dbs.run('DELETE FROM share_tokens WHERE trip_id = ?', tripId); + } + + /** + * Loads the full public trip data for a share token, filtered by the token's + * permission flags. Returns null if the token is invalid or the trip is gone. + */ + getSharedTripData(token: string): Record | null { + const shareRow = this.dbs.get( + "SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))", + token, + ); + if (!shareRow) return null; + + const tripId = shareRow.trip_id; + + // Trip + const trip = this.dbs.get( + 'SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?', + tripId, + ); + if (!trip) return null; + + // Days with assignments + const days = this.dbs.all('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC', tripId); + const dayIds = days.map(d => d.id); + + let assignments: Record = {}; + let dayNotes: Record = {}; + if (dayIds.length > 0) { + const ph = dayIds.map(() => '?').join(','); + const allAssignments = this.dbs.all(` + SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, + p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, + COALESCE(da.assignment_time, p.place_time) as place_time, + COALESCE(da.assignment_end_time, p.end_time) as end_time, + p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.day_id IN (${ph}) + ORDER BY da.order_index ASC, da.created_at ASC + `, ...dayIds); + + const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))]; + const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true }); + + const byDay: Record = {}; + for (const a of allAssignments as any[]) { + if (!byDay[a.day_id]) byDay[a.day_id] = []; + byDay[a.day_id].push({ + id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes, + place: { + id: a.place_id, name: a.place_name, description: a.place_description, + lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id, + price: a.price, place_time: a.place_time, end_time: a.end_time, + image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode, + category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null, + tags: tagsByPlace[a.place_id] || [], + } + }); + } + assignments = byDay; + + const allNotes = this.dbs.all(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`, ...dayIds); + const notesByDay: Record = {}; + for (const n of allNotes as any[]) { + if (!notesByDay[n.day_id]) notesByDay[n.day_id] = []; + notesByDay[n.day_id].push(n); + } + dayNotes = notesByDay; + } + + // Places + const places = this.dbs.all(` + SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon + FROM places p LEFT JOIN categories c ON p.category_id = c.id + WHERE p.trip_id = ? ORDER BY p.created_at DESC + `, tripId).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) })); + + // Reservations — include per-day positions so the client can render the same order as the planner + const reservations = this.dbs.all('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC', tripId); + + const dayPositions = this.dbs.all<{ reservation_id: number; day_id: number; position: number }>(` + SELECT rdp.reservation_id, rdp.day_id, rdp.position + FROM reservation_day_positions rdp + JOIN reservations r ON rdp.reservation_id = r.id + WHERE r.trip_id = ? + `, tripId); + + const posMap = new Map>(); + for (const dp of dayPositions) { + if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {}); + posMap.get(dp.reservation_id)![dp.day_id] = dp.position; + } + for (const r of reservations) { + r.day_positions = posMap.get(r.id) || null; + } + + // Accommodations + const accommodations = this.dbs.all(` + SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng + FROM day_accommodations a JOIN places p ON a.place_id = p.id + WHERE a.trip_id = ? + `, tripId); + + // Packing — a public viewer is neither owner nor recipient, so only Common items + // may surface; never a co-member's private/personal packing items (#858). + const packing = this.dbs.all('SELECT * FROM packing_items WHERE trip_id = ? AND is_private = 0 ORDER BY sort_order ASC', tripId); + + // Budget + const budget = this.dbs.all('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC', tripId); + + // Categories + const categories = this.dbs.all('SELECT * FROM categories'); + + const permissions = { + share_map: !!shareRow.share_map, + share_bookings: !!shareRow.share_bookings, + share_packing: !!shareRow.share_packing, + share_budget: !!shareRow.share_budget, + share_collab: !!shareRow.share_collab, + }; + + // Collab messages (only if owner chose to share) + const collabMessages = permissions.share_collab + ? this.dbs.all('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at', tripId) + : []; + + // Display currency the share owner sees in their Costs view. A public viewer has + // no logged-in user, so the owner's per-user `default_currency` (with the admin + // instance default already merged in by getUserSettings) is embedded in the + // payload and used by the client to convert every expense — otherwise guests + // fall back to the trip's base currency and see the wrong totals (#1361). + // getUserSettings merges admin defaults under the user's own settings, so this + // honours per-user → admin-default; we then fall back to trip currency → EUR. + let baseCurrency = (trip as { currency?: string }).currency || 'EUR'; + if (shareRow.created_by != null) { + const ownerDefault = getUserSettings(shareRow.created_by)['default_currency']; + if (typeof ownerDefault === 'string' && ownerDefault.trim()) { + baseCurrency = ownerDefault.trim(); + } + } + + // Honour every share flag server-side — the client gates these too, but it must + // not rely on that (mirrors journeyShareService). share_map covers the whole + // itinerary: days, their assignments/notes, and the place list with coordinates, + // addresses and notes. Withhold it when the owner disabled the map. + return { + trip, baseCurrency, categories, permissions, + days: permissions.share_map ? days : [], + assignments: permissions.share_map ? assignments : {}, + dayNotes: permissions.share_map ? dayNotes : {}, + places: permissions.share_map ? places : [], + reservations: permissions.share_bookings ? reservations : [], + accommodations: permissions.share_bookings ? accommodations : [], + packing: permissions.share_packing ? packing : [], + budget: permissions.share_budget ? budget : [], + collab: collabMessages, + }; + } + + /** + * Resolves the on-disk path for a cached place photo requested through a public + * share link. Validates that the token is valid + unexpired and that the place + * actually belongs to that token's trip (matched via the stored proxy URL, which + * covers both Google `placeId` and Wikimedia `coords:` pseudo-IDs without + * depending on google_place_id). Returns null — never throws — so the caller + * answers a plain 404, mirroring the authenticated bytes endpoint. + */ + getSharedPlacePhotoPath(token: string, placeId: string): string | null { + const shareRow = this.dbs.get<{ trip_id: string; share_map: number }>( + "SELECT trip_id, share_map FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))", + token, + ); + if (!shareRow) return null; + // Place photos belong to the map/itinerary section — withhold them when the + // owner disabled the map, matching getSharedTripData which no longer returns + // the places (and thus their ids) in that case. + if (!shareRow.share_map) return null; + + const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`; + const place = this.dbs.get('SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?', shareRow.trip_id, expectedUrl); + if (!place) return null; + + return serveFilePath(placeId); } - get(tripId: string) { return svc.getShareLink(tripId); } - remove(tripId: string) { return svc.deleteShareLink(tripId); } - getSharedTripData(token: string) { return svc.getSharedTripData(token); } - getSharedPlacePhotoPath(token: string, placeId: string) { return svc.getSharedPlacePhotoPath(token, placeId); } } diff --git a/server/src/services/shareService.ts b/server/src/services/shareService.ts deleted file mode 100644 index de56ff375..000000000 --- a/server/src/services/shareService.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { db, canAccessTrip } from '../db/database'; -import crypto from 'crypto'; -import { loadTagsByPlaceIds } from './queryHelpers'; -import { serveFilePath } from './placePhotoCache'; -import { getUserSettings } from './settingsService'; - -const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/'; - -/** - * Place photo proxy URLs (`/api/maps/place-photo//bytes`) are served by the - * JWT-guarded MapsController, so they 401 for an unauthenticated shared-trip - * viewer. Rewrite them to the public, token-scoped equivalent - * (`/api/shared//place-photo//bytes`) so thumbnails load in a shared - * link. A simple prefix swap keeps the already-encoded placeId segment intact, so - * the URL round-trips. Non-proxy URLs (data:, /uploads/, null) pass through. - */ -function rewritePlacePhotoUrl(url: string | null | undefined, token: string): string | null { - if (typeof url === 'string' && url.startsWith(PLACE_PHOTO_PROXY_PREFIX)) { - return `/api/shared/${token}/place-photo/${url.slice(PLACE_PHOTO_PROXY_PREFIX.length)}`; - } - return url ?? null; -} - -interface SharePermissions { - share_map?: boolean; - share_bookings?: boolean; - share_packing?: boolean; - share_budget?: boolean; - share_collab?: boolean; -} - -interface ShareTokenInfo { - token: string; - created_at: string; - share_map: boolean; - share_bookings: boolean; - share_packing: boolean; - share_budget: boolean; - share_collab: boolean; -} - -/** - * Creates a new share link or updates the permissions on an existing one. - * Returns an object with the token string and whether it was newly created. - */ -export function createOrUpdateShareLink( - tripId: string, - createdBy: number, - permissions: SharePermissions -): { token: string; created: boolean } { - const { - share_map = true, - share_bookings = true, - share_packing = false, - share_budget = false, - share_collab = false, - } = permissions; - - const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined; - if (existing) { - db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?') - .run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId); - return { token: existing.token, created: false }; - } - - // New share links default to a 90-day TTL. Existing tokens that were - // created before the expires_at migration keep NULL here and remain - // valid indefinitely until the owner rotates them; that preserves - // behaviour for anyone who's already sharing a link. - const token = crypto.randomBytes(24).toString('base64url'); - const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); - db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') - .run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt); - return { token, created: true }; -} - -/** - * Returns share token info for a trip, or null if no share link exists. - */ -export function getShareLink(tripId: string): ShareTokenInfo | null { - const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any; - if (!row) return null; - return { - token: row.token, - created_at: row.created_at, - share_map: !!row.share_map, - share_bookings: !!row.share_bookings, - share_packing: !!row.share_packing, - share_budget: !!row.share_budget, - share_collab: !!row.share_collab, - }; -} - -/** - * Deletes the share token for a trip. - */ -export function deleteShareLink(tripId: string): void { - db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId); -} - -/** - * Loads the full public trip data for a share token, filtered by the token's - * permission flags. Returns null if the token is invalid or the trip is gone. - */ -export function getSharedTripData(token: string): Record | null { - const shareRow = db.prepare( - "SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))" - ).get(token) as any; - if (!shareRow) return null; - - const tripId = shareRow.trip_id; - - // Trip - const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId); - if (!trip) return null; - - // Days with assignments - const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[]; - const dayIds = days.map(d => d.id); - - let assignments: Record = {}; - let dayNotes: Record = {}; - if (dayIds.length > 0) { - const ph = dayIds.map(() => '?').join(','); - const allAssignments = db.prepare(` - SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, - p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, - COALESCE(da.assignment_time, p.place_time) as place_time, - COALESCE(da.assignment_end_time, p.end_time) as end_time, - p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode, - c.name as category_name, c.color as category_color, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - LEFT JOIN categories c ON p.category_id = c.id - WHERE da.day_id IN (${ph}) - ORDER BY da.order_index ASC, da.created_at ASC - `).all(...dayIds); - - const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))]; - const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true }); - - const byDay: Record = {}; - for (const a of allAssignments as any[]) { - if (!byDay[a.day_id]) byDay[a.day_id] = []; - byDay[a.day_id].push({ - id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes, - place: { - id: a.place_id, name: a.place_name, description: a.place_description, - lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id, - price: a.price, place_time: a.place_time, end_time: a.end_time, - image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode, - category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null, - tags: tagsByPlace[a.place_id] || [], - } - }); - } - assignments = byDay; - - const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`).all(...dayIds); - const notesByDay: Record = {}; - for (const n of allNotes as any[]) { - if (!notesByDay[n.day_id]) notesByDay[n.day_id] = []; - notesByDay[n.day_id].push(n); - } - dayNotes = notesByDay; - } - - // Places - const places = (db.prepare(` - SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon - FROM places p LEFT JOIN categories c ON p.category_id = c.id - WHERE p.trip_id = ? ORDER BY p.created_at DESC - `).all(tripId) as any[]).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) })); - - // Reservations — include per-day positions so the client can render the same order as the planner - const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[]; - - const dayPositions = db.prepare(` - SELECT rdp.reservation_id, rdp.day_id, rdp.position - FROM reservation_day_positions rdp - JOIN reservations r ON rdp.reservation_id = r.id - WHERE r.trip_id = ? - `).all(tripId) as { reservation_id: number; day_id: number; position: number }[]; - - const posMap = new Map>(); - for (const dp of dayPositions) { - if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {}); - posMap.get(dp.reservation_id)![dp.day_id] = dp.position; - } - for (const r of reservations) { - r.day_positions = posMap.get(r.id) || null; - } - - // Accommodations - const accommodations = db.prepare(` - SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng - FROM day_accommodations a JOIN places p ON a.place_id = p.id - WHERE a.trip_id = ? - `).all(tripId); - - // Packing — a public viewer is neither owner nor recipient, so only Common items - // may surface; never a co-member's private/personal packing items (#858). - const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? AND is_private = 0 ORDER BY sort_order ASC').all(tripId); - - // Budget - const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId); - - // Categories - const categories = db.prepare('SELECT * FROM categories').all(); - - const permissions = { - share_map: !!shareRow.share_map, - share_bookings: !!shareRow.share_bookings, - share_packing: !!shareRow.share_packing, - share_budget: !!shareRow.share_budget, - share_collab: !!shareRow.share_collab, - }; - - // Collab messages (only if owner chose to share) - const collabMessages = permissions.share_collab - ? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at').all(tripId) - : []; - - // Display currency the share owner sees in their Costs view. A public viewer has - // no logged-in user, so the owner's per-user `default_currency` (with the admin - // instance default already merged in by getUserSettings) is embedded in the - // payload and used by the client to convert every expense — otherwise guests - // fall back to the trip's base currency and see the wrong totals (#1361). - // getUserSettings merges admin defaults under the user's own settings, so this - // honours per-user → admin-default; we then fall back to trip currency → EUR. - let baseCurrency = (trip as { currency?: string }).currency || 'EUR'; - if (shareRow.created_by != null) { - const ownerDefault = getUserSettings(shareRow.created_by)['default_currency']; - if (typeof ownerDefault === 'string' && ownerDefault.trim()) { - baseCurrency = ownerDefault.trim(); - } - } - - // Honour every share flag server-side — the client gates these too, but it must - // not rely on that (mirrors journeyShareService). share_map covers the whole - // itinerary: days, their assignments/notes, and the place list with coordinates, - // addresses and notes. Withhold it when the owner disabled the map. - return { - trip, baseCurrency, categories, permissions, - days: permissions.share_map ? days : [], - assignments: permissions.share_map ? assignments : {}, - dayNotes: permissions.share_map ? dayNotes : {}, - places: permissions.share_map ? places : [], - reservations: permissions.share_bookings ? reservations : [], - accommodations: permissions.share_bookings ? accommodations : [], - packing: permissions.share_packing ? packing : [], - budget: permissions.share_budget ? budget : [], - collab: collabMessages, - }; -} - -/** - * Resolves the on-disk path for a cached place photo requested through a public - * share link. Validates that the token is valid + unexpired and that the place - * actually belongs to that token's trip (matched via the stored proxy URL, which - * covers both Google `placeId` and Wikimedia `coords:` pseudo-IDs without - * depending on google_place_id). Returns null — never throws — so the caller - * answers a plain 404, mirroring the authenticated bytes endpoint. - */ -export function getSharedPlacePhotoPath(token: string, placeId: string): string | null { - const shareRow = db.prepare( - "SELECT trip_id, share_map FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))" - ).get(token) as { trip_id: string; share_map: number } | undefined; - if (!shareRow) return null; - // Place photos belong to the map/itinerary section — withhold them when the - // owner disabled the map, matching getSharedTripData which no longer returns - // the places (and thus their ids) in that case. - if (!shareRow.share_map) return null; - - const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`; - const place = db.prepare( - 'SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?' - ).get(shareRow.trip_id, expectedUrl); - if (!place) return null; - - return serveFilePath(placeId); -} diff --git a/server/tests/e2e/share.e2e.test.ts b/server/tests/e2e/share.e2e.test.ts index ddf6b7f17..98f7571db 100644 --- a/server/tests/e2e/share.e2e.test.ts +++ b/server/tests/e2e/share.e2e.test.ts @@ -1,111 +1,158 @@ /** * Share-link e2e — exercises the migrated /api/trips/:tripId/share-link and the * public /api/shared/:token endpoints through the real JwtAuthGuard against a - * temp SQLite db. The share service + permission check are mocked; this focuses - * on auth, trip-access 404, permission 403, the create-201-vs-update-200 split + * temp SQLite db. ShareService runs its real SQL via DatabaseModule (the + * DATABASE_CONNECTION factory picks up the mocked db singleton); trip access + * resolves through a real-SQL canAccessTrip over the temp db. Only the + * permission check and the photo-cache path lookup stay mocked. Focuses on + * auth, trip-access 404, permission 403, the create-201-vs-update-200 split * and the unguarded public read. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; import cookieParser from 'cookie-parser'; -import os from 'node:os'; -import path from 'node:path'; -import fs from 'node:fs'; import type { Server } from 'http'; -import { DatabaseModule } from '../../src/nest/database/database.module'; import { Test } from '@nestjs/testing'; -import { seedUser, sessionCookie } from './harness'; +import { sessionCookie } from './harness'; -const { db, canAccessTrip } = vi.hoisted(() => { +const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const Database = require('better-sqlite3'); const tmp = new Database(':memory:'); tmp.exec('PRAGMA journal_mode = WAL'); - tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); - return { db: tmp, canAccessTrip: vi.fn() }; + return { db: tmp }; }); -vi.mock('../../src/db/database', () => ({ db, canAccessTrip, closeDb: () => {}, reinitialize: () => {} })); +vi.mock('../../src/db/database', () => ({ + db, + // Real-SQL trip access over the temp db — ShareService.verifyTripAccess and + // DatabaseModule both read the mocked singleton. + canAccessTrip: (tripId: number | string, userId: number) => + db.prepare(` + SELECT t.id, t.user_id FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) + `).get(userId, tripId, userId), + isOwner: () => false, + getPlaceWithTags: () => null, + closeDb: () => {}, + reinitialize: () => {}, +})); const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); vi.mock('../../src/services/permissions', () => ({ checkPermission })); -const { shareSvc } = vi.hoisted(() => ({ - shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn(), getSharedPlacePhotoPath: vi.fn() }, -})); -vi.mock('../../src/services/shareService', () => shareSvc); +const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() })); +vi.mock('../../src/services/placePhotoCache', () => ({ serveFilePath })); +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; import { ShareModule } from '../../src/nest/share/share.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; -describe('Share-link e2e (real auth guard + temp SQLite)', () => { +describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { let server: Server; let app: Awaited>; + let tripId: number; async function build() { const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, ShareModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); + // Mirror the production APP_PIPE (app.module.ts): the DTO-typed body + // validates by metatype, exactly as it does under buildApp(). + nest.useGlobalPipes(new ZodValidationPipe()); await nest.init(); return nest; } beforeAll(async () => { - seedUser(db as never, { id: 1 }); + createTables(db); + runMigrations(db); + // seedUser() omits password_hash, which the real schema requires NOT NULL. + db.prepare( + "INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user')", + ).run(); app = await build(); server = app.getHttpServer(); - shareSvc.getSharedTripData.mockReturnValue({ trip: { id: 9 } }); }); beforeEach(() => { - canAccessTrip.mockReturnValue({ user_id: 1 }); + db.exec('DELETE FROM share_tokens'); + db.exec('DELETE FROM places'); + db.exec('DELETE FROM trip_members'); + db.exec('DELETE FROM trips'); + tripId = Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (1, ?)').run('Trip').lastInsertRowid); checkPermission.mockReturnValue(true); + serveFilePath.mockReset(); }); afterAll(async () => { await app.close(); }); + function tokenRow() { + return db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any; + } + it('401 without a session cookie', async () => { - expect((await request(server).get('/api/trips/5/share-link')).status).toBe(401); + expect((await request(server).get(`/api/trips/${tripId}/share-link`)).status).toBe(401); }); it('201 on first create, 200 on a subsequent update', async () => { - shareSvc.createOrUpdateShareLink.mockReturnValueOnce({ token: 't', created: true }); - const created = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({ share_map: true }); + const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({ share_map: true }); expect(created.status).toBe(201); - expect(created.body).toEqual({ token: 't' }); + expect(created.body).toEqual({ token: tokenRow().token }); - shareSvc.createOrUpdateShareLink.mockReturnValueOnce({ token: 't', created: false }); - const updated = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({}); + const updated = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({}); expect(updated.status).toBe(200); - expect(updated.body).toEqual({ token: 't' }); + expect(updated.body).toEqual({ token: created.body.token }); }); it('403 without share_manage', async () => { checkPermission.mockReturnValue(false); - const res = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({}); + const res = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({}); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'No permission' }); + expect(tokenRow()).toBeUndefined(); }); it('404 when the trip is not accessible', async () => { - canAccessTrip.mockReturnValue(undefined); - const res = await request(server).get('/api/trips/5/share-link').set('Cookie', sessionCookie(1)); + const res = await request(server).get('/api/trips/999999/share-link').set('Cookie', sessionCookie(1)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); }); + it('GET returns the stored flags, DELETE revokes the link', async () => { + await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({ share_budget: true }); + const info = await request(server).get(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)); + expect(info.status).toBe(200); + expect(info.body).toEqual(expect.objectContaining({ token: tokenRow().token, share_budget: true, share_packing: false })); + + const removed = await request(server).delete(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)); + expect(removed.status).toBe(200); + expect(removed.body).toEqual({ success: true }); + expect(tokenRow()).toBeUndefined(); + + const after = await request(server).get(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)); + expect(after.body).toEqual({ token: null }); + }); + it('public shared read is unguarded (200, no cookie)', async () => { - const res = await request(server).get('/api/shared/tok'); + const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({}); + const res = await request(server).get(`/api/shared/${created.body.token}`); expect(res.status).toBe(200); - expect(res.body).toEqual({ trip: { id: 9 } }); + expect(res.body.trip).toEqual(expect.objectContaining({ id: tripId, title: 'Trip' })); + expect(res.body.permissions).toEqual(expect.objectContaining({ share_map: true, share_budget: false })); }); it('public shared read 404 for an invalid token', async () => { - shareSvc.getSharedTripData.mockReturnValueOnce(null); const res = await request(server).get('/api/shared/bad'); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Invalid or expired link' }); @@ -119,17 +166,19 @@ describe('Share-link e2e (real auth guard + temp SQLite)', () => { afterAll(() => { try { fs.unlinkSync(photoFile); } catch { /* ignore */ } }); it('streams cached bytes with no cookie (unguarded) for a valid token + place', async () => { - shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(photoFile); - const res = await request(server).get('/api/shared/tok/place-photo/ChIJabc/bytes'); + const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({}); + db.prepare('INSERT INTO places (trip_id, name, image_url) VALUES (?, ?, ?)') + .run(tripId, 'Louvre', '/api/maps/place-photo/ChIJabc/bytes'); + serveFilePath.mockReturnValueOnce(photoFile); + const res = await request(server).get(`/api/shared/${created.body.token}/place-photo/ChIJabc/bytes`); expect(res.status).toBe(200); expect(res.headers['content-type']).toContain('image/jpeg'); expect(res.headers['cache-control']).toContain('immutable'); expect(Buffer.from(res.body)).toEqual(photoBytes); - expect(shareSvc.getSharedPlacePhotoPath).toHaveBeenCalledWith('tok', 'ChIJabc'); + expect(serveFilePath).toHaveBeenCalledWith('ChIJabc'); }); it('404 when the token/place does not resolve to a cached photo', async () => { - shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(null); const res = await request(server).get('/api/shared/bad/place-photo/ChIJabc/bytes'); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Photo not cached' }); diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index 27bb24dd1..ba5a140e2 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -1,77 +1,433 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +/** + * Unit tests for the DI-native ShareService — SHARE-SVC-001 through + * SHARE-SVC-028 (the SHARE-001..026 range belongs to the HTTP parity suite in + * tests/integration/share.test.ts; these pin the service SQL directly, plus + * the share.bridge delegation). Uses a real in-memory SQLite DB so SQL logic + * is exercised faithfully. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; -// The wrapper delegates to legacy helpers; mock them so no real DB is loaded. -const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); -vi.mock('../../../src/db/database', () => ({ canAccessTrip, closeDb: () => {}, reinitialize: () => {} })); +// ── DB setup ────────────────────────────────────────────────────────────────── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(` + SELECT t.id, t.user_id FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) + `).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); vi.mock('../../../src/services/permissions', () => ({ checkPermission })); -const { share } = vi.hoisted(() => ({ - share: { - createOrUpdateShareLink: vi.fn(), - getShareLink: vi.fn(), - deleteShareLink: vi.fn(), - getSharedTripData: vi.fn(), - getSharedPlacePhotoPath: vi.fn(), - }, -})); -vi.mock('../../../src/services/shareService', () => share); +const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() })); +vi.mock('../../../src/services/placePhotoCache', () => ({ serveFilePath })); +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { + createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, +} from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; import { ShareService } from '../../../src/nest/share/share.service'; -import type { DatabaseService } from '../../../src/nest/database/database.service'; +import { createOrUpdateShareLink, getShareLink, deleteShareLink } from '../../../src/nest/share/share.bridge'; import type { User } from '../../../src/types'; -function svc() { - return new ShareService({ canAccessTrip } as unknown as DatabaseService); +const svc = new ShareService(new DatabaseService(testDb)); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + checkPermission.mockReset(); + serveFilePath.mockReset(); +}); + +afterAll(() => { + testDb.close(); +}); + +function shareRow(tripId: number | string) { + return testDb.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any; } -beforeEach(() => vi.clearAllMocks()); +/** Owner + trip + a share link with all five flags on unless overridden. */ +function seedSharedTrip(flags: Record = {}) { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const { token } = svc.createOrUpdate(String(trip.id), user.id, { + share_map: true, share_bookings: true, share_packing: true, share_budget: true, share_collab: true, + ...flags, + }); + return { user, trip, token }; +} -describe('ShareService', () => { - it('verifyTripAccess delegates to canAccessTrip', () => { - canAccessTrip.mockReturnValue({ id: 5, user_id: 2 }); - expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 }); - expect(canAccessTrip).toHaveBeenCalledWith('5', 2); +// ── verifyTripAccess / canManage ────────────────────────────────────────────── + +describe('verifyTripAccess and canManage', () => { + it('SHARE-SVC-001: verifyTripAccess returns the trip for owner and member, nothing for a stranger', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + expect(svc.verifyTripAccess(String(trip.id), owner.id)?.id).toBe(trip.id); + expect(svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined(); + expect(svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy(); }); - it('canManage forwards the ownership flag when the user owns the trip', () => { + it('SHARE-SVC-002: canManage forwards the share_manage check with the ownership flag', () => { checkPermission.mockReturnValue(true); - const trip = { user_id: 1 } as never; - const user = { id: 1, role: 'user' } as User; - expect(svc().canManage(trip, user)).toBe(true); - expect(checkPermission).toHaveBeenCalledWith('share_manage', 'user', 1, 1, false); - }); - - it('canManage marks the user as a guest when they do not own the trip', () => { - checkPermission.mockReturnValue(false); - const trip = { user_id: 2 } as never; - const user = { id: 1, role: 'user' } as User; - expect(svc().canManage(trip, user)).toBe(false); - expect(checkPermission).toHaveBeenCalledWith('share_manage', 'user', 2, 1, true); - }); - - it('createOrUpdate delegates to the legacy share service', () => { - share.createOrUpdateShareLink.mockReturnValue({ token: 't', created: true }); - const perms = { share_map: true }; - expect(svc().createOrUpdate('5', 2, perms)).toEqual({ token: 't', created: true }); - expect(share.createOrUpdateShareLink).toHaveBeenCalledWith('5', 2, perms); - }); - - it('get / remove / getSharedTripData / getSharedPlacePhotoPath delegate', () => { - share.getShareLink.mockReturnValue({ token: 't' }); - expect(svc().get('5')).toEqual({ token: 't' }); - expect(share.getShareLink).toHaveBeenCalledWith('5'); - - svc().remove('5'); - expect(share.deleteShareLink).toHaveBeenCalledWith('5'); - - share.getSharedTripData.mockReturnValue({ trip: { id: 9 } }); - expect(svc().getSharedTripData('tok')).toEqual({ trip: { id: 9 } }); - expect(share.getSharedTripData).toHaveBeenCalledWith('tok'); - - share.getSharedPlacePhotoPath.mockReturnValue('/cache/p1.jpg'); - expect(svc().getSharedPlacePhotoPath('tok', 'p1')).toBe('/cache/p1.jpg'); - expect(share.getSharedPlacePhotoPath).toHaveBeenCalledWith('tok', 'p1'); + const trip = { id: 5, user_id: 1 } as any; + const owner = { id: 1, role: 'user' } as User; + const member = { id: 2, role: 'user' } as User; + expect(svc.canManage(trip, owner)).toBe(true); + expect(checkPermission).toHaveBeenLastCalledWith('share_manage', 'user', 1, 1, false); + svc.canManage(trip, member); + expect(checkPermission).toHaveBeenLastCalledWith('share_manage', 'user', 1, 2, true); + }); +}); + +// ── createOrUpdate ──────────────────────────────────────────────────────────── + +describe('createOrUpdate', () => { + it('SHARE-SVC-003: creates a link with default flags (map/bookings on, rest off) and a 90-day expiry', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const before = Date.now(); + const result = svc.createOrUpdate(String(trip.id), user.id, {}); + expect(result.created).toBe(true); + expect(result.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url + const row = shareRow(trip.id); + expect(row.token).toBe(result.token); + expect(row.created_by).toBe(user.id); + expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab]) + .toEqual([1, 1, 0, 0, 0]); + const ninetyDays = 90 * 24 * 60 * 60 * 1000; + const expires = new Date(row.expires_at).getTime(); + expect(expires).toBeGreaterThanOrEqual(before + ninetyDays - 5000); + expect(expires).toBeLessThanOrEqual(Date.now() + ninetyDays + 5000); + }); + + it('SHARE-SVC-004: explicit flags override the defaults in both directions', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + svc.createOrUpdate(String(trip.id), user.id, { + share_map: false, share_bookings: false, share_packing: true, share_budget: true, share_collab: true, + }); + const row = shareRow(trip.id); + expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab]) + .toEqual([0, 0, 1, 1, 1]); + }); + + it('SHARE-SVC-005: a second call updates the flags, keeps the token, and reports created:false', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const first = svc.createOrUpdate(String(trip.id), user.id, {}); + const second = svc.createOrUpdate(String(trip.id), user.id, { share_budget: true }); + expect(second).toEqual({ token: first.token, created: false }); + expect(shareRow(trip.id).share_budget).toBe(1); + }); + + it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and never touches expires_at', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + svc.createOrUpdate(String(trip.id), user.id, { share_packing: true }); + testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2099-01-01T00:00:00.000Z', trip.id); + svc.createOrUpdate(String(trip.id), user.id, {}); + const row = shareRow(trip.id); + // Omitted share_packing fell back to its default (off) — destructuring + // defaults apply on every call, not only on create. + expect(row.share_packing).toBe(0); + expect(row.expires_at).toBe('2099-01-01T00:00:00.000Z'); + }); +}); + +// ── get / remove ────────────────────────────────────────────────────────────── + +describe('get and remove', () => { + it('SHARE-SVC-007: get returns null when no link exists', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(svc.get(String(trip.id))).toBeNull(); + }); + + it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const { token } = svc.createOrUpdate(String(trip.id), user.id, { share_packing: true }); + const info = svc.get(String(trip.id)); + expect(info).toEqual({ + token, + created_at: expect.any(String), + share_map: true, + share_bookings: true, + share_packing: true, + share_budget: false, + share_collab: false, + }); + }); + + it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + svc.createOrUpdate(String(trip.id), user.id, {}); + svc.remove(String(trip.id)); + expect(shareRow(trip.id)).toBeUndefined(); + expect(() => svc.remove(String(trip.id))).not.toThrow(); + }); +}); + +// ── getSharedTripData ───────────────────────────────────────────────────────── + +describe('getSharedTripData', () => { + it('SHARE-SVC-010: returns null for an unknown token', () => { + expect(svc.getSharedTripData('nope')).toBeNull(); + }); + + it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', () => { + const { trip, token } = seedSharedTrip(); + testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id); + expect(svc.getSharedTripData(token)).toBeNull(); + testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id); + expect(svc.getSharedTripData(token)).not.toBeNull(); + }); + + it('SHARE-SVC-012: returns null when the trip row is gone', () => { + const { trip, token } = seedSharedTrip(); + // The token normally cascades away with its trip; orphan it deliberately + // to pin the defensive `if (!trip) return null` branch. + testDb.exec('PRAGMA foreign_keys = OFF'); + testDb.prepare('DELETE FROM trips WHERE id = ?').run(trip.id); + testDb.exec('PRAGMA foreign_keys = ON'); + expect(svc.getSharedTripData(token)).toBeNull(); + }); + + it('SHARE-SVC-013: returns the trip projection, coerced permissions and grouped itinerary', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id, { date: '2025-06-01' }); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }); + createDayAssignment(testDb, day.id, place.id, { notes: 'go early' }); + const data = svc.getSharedTripData(token)!; + expect(data).not.toBeNull(); + expect(data.trip).toEqual(expect.objectContaining({ id: trip.id, title: trip.title })); + // Explicit column list — no owner id or internal fields on the trip row. + expect(Object.keys(data.trip).sort()).toEqual( + ['cover_image', 'currency', 'description', 'end_date', 'id', 'start_date', 'title'], + ); + expect(data.permissions).toEqual({ + share_map: true, share_bookings: true, share_packing: true, share_budget: true, share_collab: true, + }); + expect(data.days).toHaveLength(1); + const entries = data.assignments[day.id]; + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual(expect.objectContaining({ day_id: day.id, notes: 'go early' })); + expect(entries[0].place).toEqual(expect.objectContaining({ + id: place.id, name: 'Louvre', tags: [], + })); + expect(entries[0].place.category).toEqual(expect.objectContaining({ id: place.category_id })); + expect(data.collab).toEqual([]); + }); + + it('SHARE-SVC-014: COALESCEs assignment times over place times', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id); + const a = createDayAssignment(testDb, day.id, place.id); + let entry = svc.getSharedTripData(token)!.assignments[day.id][0]; + expect(entry.place.place_time).toBe('09:00'); + expect(entry.place.end_time).toBe('10:00'); + testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('14:00', '15:00', a.id); + entry = svc.getSharedTripData(token)!.assignments[day.id][0]; + expect(entry.place.place_time).toBe('14:00'); + expect(entry.place.end_time).toBe('15:00'); + }); + + it('SHARE-SVC-015: orders assignments by order_index, then created_at as tiebreaker', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id); + const p1 = createPlace(testDb, trip.id, { name: 'First' }); + const p2 = createPlace(testDb, trip.id, { name: 'Second' }); + testDb.prepare( + "INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')" + ).run(day.id, p2.id); + testDb.prepare( + "INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')" + ).run(day.id, p1.id); + const entries = svc.getSharedTripData(token)!.assignments[day.id]; + expect(entries.map((e: any) => e.place.name)).toEqual(['First', 'Second']); + }); + + it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id); + const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id); + const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id); + testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, 3)') + .run(r1.lastInsertRowid, day.id); + const reservations = svc.getSharedTripData(token)!.reservations; + const flight = reservations.find((r: any) => r.id === r1.lastInsertRowid); + const hotel = reservations.find((r: any) => r.id === r2.lastInsertRowid); + expect(flight.day_positions).toEqual({ [day.id]: 3 }); + expect(hotel.day_positions).toBeNull(); + }); + + it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', () => { + const { user, trip, token } = seedSharedTrip(); + testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Common', 0, ?)").run(trip.id, user.id); + testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, user.id); + const packing = svc.getSharedTripData(token)!.packing; + expect(packing.map((p: any) => p.name)).toEqual(['Common']); + }); + + it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', () => { + const { user, trip, token } = seedSharedTrip(); + testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Hello', 0)").run(trip.id, user.id); + testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Gone', 1)").run(trip.id, user.id); + const collab = svc.getSharedTripData(token)!.collab; + expect(collab).toHaveLength(1); + expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username })); + + svc.createOrUpdate(String(trip.id), user.id, { share_collab: false }); + expect(svc.getSharedTripData(token)!.collab).toEqual([]); + }); + + it('SHARE-SVC-019: withholds each section when its flag is off', () => { + const { trip, token } = seedSharedTrip({ + share_map: false, share_bookings: false, share_packing: false, share_budget: false, share_collab: false, + }); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + createDayAssignment(testDb, day.id, place.id); + testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id); + testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Socks')").run(trip.id); + testDb.prepare("INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, 'Food', 'food', 10)").run(trip.id); + const data = svc.getSharedTripData(token)!; + expect(data.days).toEqual([]); + expect(data.assignments).toEqual({}); + expect(data.dayNotes).toEqual({}); + expect(data.places).toEqual([]); + expect(data.reservations).toEqual([]); + expect(data.accommodations).toEqual([]); + expect(data.packing).toEqual([]); + expect(data.budget).toEqual([]); + expect(data.collab).toEqual([]); + }); + + it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', () => { + const { user, trip, token } = seedSharedTrip(); + expect(svc.getSharedTripData(token)!.baseCurrency).toBe('EUR'); + testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id); + expect(svc.getSharedTripData(token)!.baseCurrency).toBe('USD'); + testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ' CHF ')").run(user.id); + expect(svc.getSharedTripData(token)!.baseCurrency).toBe('CHF'); + }); + + it('SHARE-SVC-021: rewrites place-photo proxy URLs to the token-scoped route, passing others through', () => { + const { trip, token } = seedSharedTrip(); + const proxied = createPlace(testDb, trip.id, { name: 'Proxied' }); + const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' }); + createPlace(testDb, trip.id, { name: 'Bare' }); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', proxied.id); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/pic.jpg', uploaded.id); + const byName = Object.fromEntries( + svc.getSharedTripData(token)!.places.map((p: any) => [p.name, p.image_url]), + ); + expect(byName['Proxied']).toBe(`/api/shared/${token}/place-photo/ChIJabc/bytes`); + expect(byName['Uploaded']).toBe('/uploads/pic.jpg'); + expect(byName['Bare']).toBeNull(); + }); +}); + +// ── getSharedPlacePhotoPath ─────────────────────────────────────────────────── + +describe('getSharedPlacePhotoPath', () => { + it('SHARE-SVC-022: returns null for an unknown or expired token', () => { + expect(svc.getSharedPlacePhotoPath('nope', 'ChIJabc')).toBeNull(); + const { trip, token } = seedSharedTrip(); + testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id); + expect(svc.getSharedPlacePhotoPath(token, 'ChIJabc')).toBeNull(); + expect(serveFilePath).not.toHaveBeenCalled(); + }); + + it('SHARE-SVC-023: returns null when the owner disabled the map section', () => { + const { trip, token } = seedSharedTrip({ share_map: false }); + const place = createPlace(testDb, trip.id); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id); + expect(svc.getSharedPlacePhotoPath(token, 'ChIJabc')).toBeNull(); + expect(serveFilePath).not.toHaveBeenCalled(); + }); + + it('SHARE-SVC-024: returns null when no place in the trip carries the proxy URL', () => { + const { token } = seedSharedTrip(); + expect(svc.getSharedPlacePhotoPath(token, 'ChIJabc')).toBeNull(); + expect(serveFilePath).not.toHaveBeenCalled(); + }); + + it('SHARE-SVC-025: resolves via serveFilePath when the encoded placeId matches the stored URL', () => { + const { trip, token } = seedSharedTrip(); + const place = createPlace(testDb, trip.id); + // Wikimedia pseudo-IDs contain characters that must round-trip encoded. + const placeId = 'coords:48.8,2.3'; + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?') + .run(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, place.id); + serveFilePath.mockReturnValue('/cache/abc.jpg'); + expect(svc.getSharedPlacePhotoPath(token, placeId)).toBe('/cache/abc.jpg'); + expect(serveFilePath).toHaveBeenCalledWith(placeId); + }); +}); + +// ── share.bridge delegation ─────────────────────────────────────────────────── + +describe('share.bridge delegation', () => { + it('SHARE-SVC-026: createOrUpdateShareLink delegates to ShareService.createOrUpdate', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const result = createOrUpdateShareLink(String(trip.id), user.id, { share_budget: true }); + expect(result.created).toBe(true); + expect(shareRow(trip.id).share_budget).toBe(1); + }); + + it('SHARE-SVC-027: getShareLink delegates to ShareService.get', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(getShareLink(String(trip.id))).toBeNull(); + const { token } = svc.createOrUpdate(String(trip.id), user.id, {}); + expect(getShareLink(String(trip.id))?.token).toBe(token); + }); + + it('SHARE-SVC-028: deleteShareLink delegates to ShareService.remove', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + svc.createOrUpdate(String(trip.id), user.id, {}); + deleteShareLink(String(trip.id)); + expect(shareRow(trip.id)).toBeUndefined(); }); });