mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
71aa8f8051
Replaces the old model where journey_photos was keyed per-entry with a per-journey gallery table (one row per unique photo per journey) and a new junction table journey_entry_photos that links gallery photos to entries. Key changes: - Migration 121: renames old journey_photos to journey_photos_old, creates the new gallery table + junction table, backfills both from existing data, drops the backup, removes synthetic 'Gallery' / '[Trip Photos]' wrapper entries - journeyService: rewrites photo helpers (JP_SELECT/JOIN now joins via journey_entry_photos → journey_photos → trek_photos); adds uploadGalleryPhotos, addProviderPhotoToGallery, unlinkPhotoFromEntry, deleteGalleryPhoto; simplifies deletePhoto and linkPhotoToEntry against the new schema; syncTripPhotos inserts directly into the gallery instead of a wrapper entry - journeyShareService: updates public photo and asset validation queries to join through the gallery table instead of entry_id; getPublicJourney now returns a dedicated gallery array alongside per-entry photos - journey routes: adds gallery upload, provider-photo, and delete endpoints (POST/DELETE /:id/gallery/*); adds unlink-from-entry route (DELETE /entries/:entryId/photos/:journeyPhotoId); updates link-photo to accept journey_photo_id with a backwards-compat photo_id alias - types: adds GalleryPhoto interface - client api: adds uploadGalleryPhotos, addProviderPhotosToGallery, unlinkPhoto, deleteGalleryPhoto; updates linkPhoto param name to journeyPhotoId - journeyStore: adds GalleryPhoto type, gallery field on JourneyDetail, uploadGalleryPhotos / unlinkPhoto / deleteGalleryPhoto store actions - JourneyDetailPage + tests: updated to work with the new gallery model
164 lines
5.9 KiB
TypeScript
164 lines
5.9 KiB
TypeScript
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;
|
|
if (!photo) {
|
|
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
|
return journey ? { ownerId: journey.user_id } : 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
|
|
const stats = {
|
|
entries: entries.length,
|
|
photos: gallery.length,
|
|
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
|
};
|
|
|
|
return {
|
|
journey: {
|
|
title: journey.title,
|
|
subtitle: journey.subtitle,
|
|
cover_image: journey.cover_image,
|
|
status: journey.status,
|
|
},
|
|
entries: enrichedEntries,
|
|
gallery,
|
|
stats,
|
|
permissions: {
|
|
share_timeline: !!row.share_timeline,
|
|
share_gallery: !!row.share_gallery,
|
|
share_map: !!row.share_map,
|
|
},
|
|
};
|
|
}
|