mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
chore: move share backend to proper DI
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/<id>/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/<token>/place-photo/<id>/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<typeof svc.createOrUpdateShareLink>[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<any>('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<string, any> | null {
|
||||
const shareRow = this.dbs.get<any>(
|
||||
"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<any>('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC', tripId);
|
||||
const dayIds = days.map(d => d.id);
|
||||
|
||||
let assignments: Record<number, any[]> = {};
|
||||
let dayNotes: Record<number, any[]> = {};
|
||||
if (dayIds.length > 0) {
|
||||
const ph = dayIds.map(() => '?').join(',');
|
||||
const allAssignments = this.dbs.all<any>(`
|
||||
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<number, any[]> = {};
|
||||
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<any>(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`, ...dayIds);
|
||||
const notesByDay: Record<number, any[]> = {};
|
||||
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<any>(`
|
||||
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<any>('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<number, Record<number, number>>();
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -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/<id>/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/<token>/place-photo/<id>/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<string, any> | 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<number, any[]> = {};
|
||||
let dayNotes: Record<number, any[]> = {};
|
||||
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<number, any[]> = {};
|
||||
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<number, any[]> = {};
|
||||
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<number, Record<number, number>>();
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user