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:
Maurice
2026-04-13 20:08:31 +02:00
parent e629548a42
commit c0c59b6d80
34 changed files with 883 additions and 198 deletions
+58 -11
View File
@@ -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);
}
+13 -14
View File
@@ -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);