File size: 7,226 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
import { db } from '../db/database';
import crypto from 'crypto';
import { isOwner } from './journeyService';

interface JourneySharePermissions {
  share_timeline?: boolean;
  share_gallery?: boolean;
  share_map?: boolean;
}

interface JourneyShareTokenInfo {
  token: string;
  created_at: string;
  share_timeline: boolean;
  share_gallery: boolean;
  share_map: boolean;
}

export function createOrUpdateJourneyShareLink(
  journeyId: number,
  createdBy: number,
  permissions: JourneySharePermissions
): { token: string; created: boolean } | null {
  // Public sharing is an owner-only action — editors/viewers must not be
  // able to publish the journey or change which screens are shared.
  if (!isOwner(journeyId, createdBy)) return null;

  const {
    share_timeline = true,
    share_gallery = true,
    share_map = true,
  } = permissions;

  const existing = db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
  if (existing) {
    db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
      .run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
    return { token: existing.token, created: false };
  }

  const token = crypto.randomBytes(24).toString('base64url');
  db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
    .run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
  return { token, created: true };
}

export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null {
  const row = db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any;
  if (!row) return null;
  return {
    token: row.token,
    created_at: row.created_at,
    share_timeline: !!row.share_timeline,
    share_gallery: !!row.share_gallery,
    share_map: !!row.share_map,
  };
}

export function deleteJourneyShareLink(journeyId: number, userId: number): boolean {
  if (!isOwner(journeyId, userId)) return false;
  db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
  return true;
}

export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
  const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
  if (!row) return null;
  const photo = db.prepare(`
    SELECT gp.photo_id, tkp.owner_id, gp.journey_id
    FROM journey_photos gp
    JOIN trek_photos tkp ON tkp.id = gp.photo_id
    WHERE gp.photo_id = ? AND gp.journey_id = ?
  `).get(photoId, row.journey_id) as any;
  if (!photo) return null;
  const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
  return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null;
}

export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
  const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
  if (!row) return null;
  const photo = db.prepare(`
    SELECT tkp.owner_id FROM journey_photos gp
    JOIN trek_photos tkp ON tkp.id = gp.photo_id
    WHERE tkp.asset_id = ? AND gp.journey_id = ?
  `).get(assetId, row.journey_id) as any;
  // Only resolve assets that actually belong to this shared journey.
  if (!photo) return null;
  return { ownerId: photo.owner_id };
}

export function getPublicJourney(token: string) {
  const row = db.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token) as any;
  if (!row) return null;

  const journey = db.prepare('SELECT * FROM journeys WHERE id = ?').get(row.journey_id) as any;
  if (!journey) return null;

  // Entries with photos
  const entries = db.prepare(`
    SELECT je.* FROM journey_entries je
    WHERE je.journey_id = ? AND je.type != 'skeleton'
    ORDER BY je.entry_date, je.sort_order
  `).all(row.journey_id) as any[];

  const photos = db.prepare(`
    SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
           tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
    FROM journey_entry_photos jep
    JOIN journey_photos gp ON gp.id = jep.journey_photo_id
    JOIN trek_photos tkp ON tkp.id = gp.photo_id
    WHERE gp.journey_id = ?
    ORDER BY jep.sort_order
  `).all(row.journey_id) as any[];

  const photosByEntry: Record<number, any[]> = {};
  for (const p of photos) {
    (photosByEntry[p.entry_id] ||= []).push(p);
  }

  const gallery = db.prepare(`
    SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
           tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
    FROM journey_photos gp
    JOIN trek_photos tkp ON tkp.id = gp.photo_id
    WHERE gp.journey_id = ?
    ORDER BY gp.sort_order
  `).all(row.journey_id) as any[];

  const enrichedEntries = entries
    .map(e => ({
      ...e,
      tags: e.tags ? JSON.parse(e.tags) : [],
      pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
      photos: photosByEntry[e.id] || [],
    }));

  // Stats are derived from the full data so the overview pills stay accurate
  // even when a section is hidden.
  const stats = {
    entries: entries.length,
    photos: gallery.length,
    places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
  };

  const shareTimeline = !!row.share_timeline;
  const shareGallery = !!row.share_gallery;
  const shareMap = !!row.share_map;

  // Honour the share flags server-side so the API only returns the sections the
  // owner enabled (the client gates these too, but it must not rely on that).
  let publicEntries: Record<string, unknown>[] = [];
  if (shareTimeline) {
    // Include the full entry, but drop GPS unless the map is shared and inline
    // photos unless the gallery is shared.
    publicEntries = enrichedEntries.map(e => {
      const projected: Record<string, unknown> = { ...e };
      if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
      if (!shareGallery) projected.photos = [];
      return projected;
    });
  } else if (shareMap) {
    // Map-only share: just enough to plot markers, no story/photos/mood.
    publicEntries = enrichedEntries.map(e => ({
      id: e.id,
      journey_id: e.journey_id,
      type: e.type,
      entry_date: e.entry_date,
      title: e.title,
      location_name: e.location_name,
      location_lat: e.location_lat,
      location_lng: e.location_lng,
      sort_order: e.sort_order,
    }));
  }

  return {
    journey: {
      title: journey.title,
      subtitle: journey.subtitle,
      cover_image: journey.cover_image,
      status: journey.status,
    },
    entries: publicEntries,
    gallery: shareGallery ? gallery : [],
    stats,
    permissions: {
      share_timeline: shareTimeline,
      share_gallery: shareGallery,
      share_map: shareMap,
    },
  };
}