mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
feat: unified photo provider abstraction layer (#584)
Introduce trek_photos as central photo registry. Frontend uses /api/photos/:id/:kind instead of provider-specific URLs. Adding a new photo provider is now backend-only work. - New trek_photos table (migration 98) with photo_id FK in trip_photos and journey_photos - Unified /api/photos/:id/thumbnail|original|info endpoint - photoResolverService for central resolution and streaming - ProviderPicker: add "All Photos" tab, rename tabs, fix i18n - Localize all hardcoded strings in JourneyDetailPage (14 langs) - Fix date formatting to use browser locale instead of hardcoded 'en' - Journey stats as styled tile cards
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
|
||||
|
||||
function ts(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface
|
||||
const JP_SELECT = `
|
||||
jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
|
||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
||||
`;
|
||||
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id';
|
||||
|
||||
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeUserId?: number) {
|
||||
const contributors = db.prepare(
|
||||
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
|
||||
@@ -105,7 +113,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
|
||||
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
|
||||
).all(journeyId) as JourneyPhoto[];
|
||||
|
||||
// group photos by entry
|
||||
@@ -272,8 +280,8 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
||||
// import trip_photos into journey when a trip is linked
|
||||
function syncTripPhotos(journeyId: number, tripId: number) {
|
||||
const tripPhotos = db.prepare(
|
||||
'SELECT * FROM trip_photos WHERE trip_id = ?'
|
||||
).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[];
|
||||
'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
|
||||
).all(tripId) as { photo_id: number; user_id: number; shared: number }[];
|
||||
if (!tripPhotos.length) return;
|
||||
|
||||
const now = ts();
|
||||
@@ -285,7 +293,6 @@ function syncTripPhotos(journeyId: number, tripId: number) {
|
||||
`).get(journeyId, tripId) as { id: number } | undefined;
|
||||
|
||||
if (!photoEntry) {
|
||||
// get trip date for the entry
|
||||
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
|
||||
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
|
||||
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
|
||||
@@ -297,19 +304,19 @@ function syncTripPhotos(journeyId: number, tripId: number) {
|
||||
photoEntry = { id: Number(res.lastInsertRowid) };
|
||||
}
|
||||
|
||||
// import each trip photo, skip duplicates
|
||||
// import each trip photo, skip duplicates (by photo_id)
|
||||
for (const tp of tripPhotos) {
|
||||
const exists = db.prepare(
|
||||
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?'
|
||||
).get(photoEntry.id, tp.provider, tp.asset_id);
|
||||
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
|
||||
).get(photoEntry.id, tp.photo_id);
|
||||
if (exists) continue;
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
|
||||
INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +431,7 @@ export function listEntries(journeyId: number, userId: number) {
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
|
||||
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
|
||||
).all(journeyId) as JourneyPhoto[];
|
||||
|
||||
const photosByEntry: Record<number, JourneyPhoto[]> = {};
|
||||
@@ -579,15 +586,16 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
const now = ts();
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at)
|
||||
VALUES (?, 'local', ?, ?, ?, ?, ?)
|
||||
`).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
|
||||
@@ -595,19 +603,21 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
|
||||
// skip if already added
|
||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?').get(entryId, provider, assetId);
|
||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
|
||||
if (exists) return null;
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
const now = ts();
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
|
||||
@@ -615,7 +625,7 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const source = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto | undefined;
|
||||
const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined;
|
||||
if (!source) return null;
|
||||
|
||||
if (source.entry_id === entryId) return source;
|
||||
@@ -634,16 +644,19 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
||||
db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
|
||||
// Get the trek_photo_id from the journey_photo, then update the central registry
|
||||
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
|
||||
if (!jp) return;
|
||||
setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId);
|
||||
}
|
||||
|
||||
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.*, je.journey_id FROM journey_photos jp
|
||||
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ?
|
||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
||||
@@ -658,12 +671,12 @@ export function updatePhoto(photoId: number, userId: number, data: { caption?: s
|
||||
|
||||
values.push(photoId);
|
||||
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.*, je.journey_id FROM journey_photos jp
|
||||
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ?
|
||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
||||
|
||||
@@ -59,7 +59,9 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
|
||||
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 jp.*, je.journey_id FROM journey_photos jp
|
||||
SELECT jp.photo_id, tkp.owner_id, je.journey_id
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ? AND je.journey_id = ?
|
||||
`).get(photoId, row.journey_id) as any;
|
||||
@@ -71,14 +73,13 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
|
||||
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;
|
||||
// Check if this asset belongs to any photo in the shared journey
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.owner_id FROM journey_photos jp
|
||||
SELECT tkp.owner_id FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.asset_id = ? AND je.journey_id = ?
|
||||
WHERE tkp.asset_id = ? AND je.journey_id = ?
|
||||
`).get(assetId, row.journey_id) as any;
|
||||
if (!photo) {
|
||||
// Fallback: get journey owner
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
return journey ? { ownerId: journey.user_id } : null;
|
||||
}
|
||||
@@ -100,7 +101,10 @@ export function getPublicJourney(token: string) {
|
||||
`).all(row.journey_id) as any[];
|
||||
|
||||
const photos = db.prepare(`
|
||||
SELECT jp.* FROM journey_photos jp
|
||||
SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
|
||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE je.journey_id = ?
|
||||
ORDER BY jp.sort_order
|
||||
|
||||
@@ -129,15 +129,15 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
||||
const journeyPhoto = db.prepare(`
|
||||
SELECT jp.entry_id, je.journey_id
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON je.id = jp.entry_id
|
||||
WHERE jp.asset_id = ?
|
||||
AND jp.provider = ?
|
||||
AND jp.owner_id = ?
|
||||
WHERE tkp.asset_id = ?
|
||||
AND tkp.provider = ?
|
||||
AND tkp.owner_id = ?
|
||||
LIMIT 1
|
||||
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
|
||||
if (!journeyPhoto) return false;
|
||||
|
||||
// Check if requesting user is the journey owner or a contributor
|
||||
const access = db.prepare(`
|
||||
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
|
||||
UNION ALL
|
||||
@@ -147,15 +147,16 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
||||
return !!access;
|
||||
}
|
||||
|
||||
// Regular trip photos
|
||||
// Regular trip photos — join through trek_photos
|
||||
const sharedAsset = db.prepare(`
|
||||
SELECT 1
|
||||
FROM trip_photos
|
||||
WHERE user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
AND trip_id = ?
|
||||
AND shared = 1
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.user_id = ?
|
||||
AND tkp.asset_id = ?
|
||||
AND tkp.provider = ?
|
||||
AND tp.trip_id = ?
|
||||
AND tp.shared = 1
|
||||
LIMIT 1
|
||||
`).get(ownerUserId, assetId, provider, tripId);
|
||||
|
||||
@@ -166,6 +167,52 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
||||
}
|
||||
|
||||
|
||||
// ── Unified photo access check (trek_photos based) ──────────────────────
|
||||
|
||||
export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean {
|
||||
const photo = db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(trekPhotoId) as { id: number; provider: string; owner_id: number | null } | undefined;
|
||||
if (!photo) return false;
|
||||
|
||||
// Owner always has access
|
||||
if (photo.owner_id === requestingUserId) return true;
|
||||
|
||||
// Check trip_photos — is this photo shared in a trip the user has access to?
|
||||
const tripAccess = db.prepare(`
|
||||
SELECT 1 FROM trip_photos tp
|
||||
WHERE tp.photo_id = ?
|
||||
AND tp.shared = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
||||
if (tripAccess) return true;
|
||||
|
||||
// Check journey_photos — is this photo in a journey the user can access?
|
||||
const journeyAccess = db.prepare(`
|
||||
SELECT 1 FROM journey_photos jp
|
||||
JOIN journey_entries je ON je.id = jp.entry_id
|
||||
WHERE jp.photo_id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ?
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
||||
if (journeyAccess) return true;
|
||||
|
||||
// Local photos without owner (uploaded files) — check if user has journey access
|
||||
if (photo.provider === 'local' && !photo.owner_id) {
|
||||
return !!journeyAccess;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------
|
||||
//helpers for album link syncing
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db } from '../../db/database';
|
||||
import type { TrekPhoto } from '../../types';
|
||||
import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService';
|
||||
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
||||
import type { ServiceResult, AssetInfo } from './helpersService';
|
||||
import { fail, success } from './helpersService';
|
||||
|
||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||
|
||||
export function getOrCreateTrekPhoto(
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||
).run(provider, assetId, ownerId);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function getOrCreateLocalTrekPhoto(
|
||||
filePath: string,
|
||||
thumbnailPath?: string | null,
|
||||
width?: number | null,
|
||||
height?: number | null,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?"
|
||||
).get(filePath) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height) VALUES (?, ?, ?, ?, ?)'
|
||||
).run('local', filePath, thumbnailPath || null, width || null, height || null);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function resolveTrekPhoto(photoId: number): TrekPhoto | null {
|
||||
return db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoId) as TrekPhoto | undefined || null;
|
||||
}
|
||||
|
||||
// ── Streaming ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function streamPhoto(
|
||||
res: Response,
|
||||
userId: number,
|
||||
photoId: number,
|
||||
kind: 'thumbnail' | 'original',
|
||||
): Promise<void> {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo) {
|
||||
res.status(404).json({ error: 'Photo not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
const filePath = path.join(__dirname, '../../../uploads', photo.file_path!);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(filePath);
|
||||
return;
|
||||
}
|
||||
case 'immich': {
|
||||
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
|
||||
return;
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
res.status(400).json({ error: `Unknown provider: ${photo.provider}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Asset Info ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getPhotoInfo(
|
||||
userId: number,
|
||||
photoId: number,
|
||||
): Promise<ServiceResult<AssetInfo>> {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo) return fail('Photo not found', 404);
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
return success({
|
||||
id: String(photo.id),
|
||||
takenAt: photo.created_at,
|
||||
city: null,
|
||||
country: null,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
fileName: photo.file_path?.split('/').pop() || null,
|
||||
} as AssetInfo);
|
||||
}
|
||||
case 'immich': {
|
||||
const result = await getImmichAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
if (result.error) return fail(result.error, result.status || 500);
|
||||
return success(result.data as AssetInfo);
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
}
|
||||
default:
|
||||
return fail(`Unknown provider: ${photo.provider}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update provider on existing trek_photo (for Immich upload sync) ─────
|
||||
|
||||
export function setTrekPhotoProvider(
|
||||
trekPhotoId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
): void {
|
||||
db.prepare(
|
||||
'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?'
|
||||
).run(provider, assetId, ownerId, trekPhotoId);
|
||||
}
|
||||
|
||||
// ── Delete local file for a trek_photo ──────────────────────────────────
|
||||
|
||||
export function getTrekPhotoFilePath(photoId: number): string | null {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo || photo.provider !== 'local' || !photo.file_path) return null;
|
||||
return path.join(__dirname, '../../../uploads', photo.file_path);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mapDbError,
|
||||
Selection,
|
||||
} from './helpersService';
|
||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||
|
||||
|
||||
function _providers(): Array<{id: string; enabled: boolean}> {
|
||||
@@ -45,13 +46,14 @@ export function listTripPhotos(tripId: string, userId: number): ServiceResult<an
|
||||
}
|
||||
|
||||
const photos = db.prepare(`
|
||||
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
|
||||
SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
|
||||
u.username, u.avatar
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
JOIN users u ON tp.user_id = u.id
|
||||
WHERE tp.trip_id = ?
|
||||
AND (tp.user_id = ? OR tp.shared = 1)
|
||||
AND tp.provider IN (${enabledProviders.map(() => '?').join(',')})
|
||||
AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
|
||||
ORDER BY tp.added_at ASC
|
||||
`).all(tripId, userId, ...enabledProviders);
|
||||
|
||||
@@ -108,9 +110,10 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId
|
||||
return providerResult as ServiceResult<boolean>;
|
||||
}
|
||||
try {
|
||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null);
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
|
||||
return success(result.changes > 0);
|
||||
}
|
||||
catch (error) {
|
||||
@@ -163,8 +166,7 @@ export async function addTripPhotos(
|
||||
export async function setTripPhotoSharing(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
photoId: number,
|
||||
shared: boolean,
|
||||
sid?: string,
|
||||
): Promise<ServiceResult<true>> {
|
||||
@@ -179,9 +181,8 @@ export async function setTripPhotoSharing(
|
||||
SET shared = ?
|
||||
WHERE trip_id = ?
|
||||
AND user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
`).run(shared ? 1 : 0, tripId, userId, assetId, provider);
|
||||
AND photo_id = ?
|
||||
`).run(shared ? 1 : 0, tripId, userId, photoId);
|
||||
|
||||
await _notifySharedTripPhotos(tripId, userId, 1);
|
||||
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||
@@ -194,8 +195,7 @@ export async function setTripPhotoSharing(
|
||||
export function removeTripPhoto(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
photoId: number,
|
||||
sid?: string,
|
||||
): ServiceResult<true> {
|
||||
const access = canAccessTrip(tripId, userId);
|
||||
@@ -208,9 +208,8 @@ export function removeTripPhoto(
|
||||
DELETE FROM trip_photos
|
||||
WHERE trip_id = ?
|
||||
AND user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
`).run(tripId, userId, assetId, provider);
|
||||
AND photo_id = ?
|
||||
`).run(tripId, userId, photoId);
|
||||
|
||||
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user