File size: 10,721 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto';
import { loadTagsByPlaceIds } from './queryHelpers';
import { serveFilePath } from './placePhotoCache';

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
  const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? 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)
    : [];

  return {
    trip, days, assignments, dayNotes, places, categories, permissions,
    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 FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
  ).get(token) as { trip_id: string } | undefined;
  if (!shareRow) 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);
}