Merge branch 'dev' into feat/login-language-detection-dropdown

This commit is contained in:
Isaias Tavares
2026-04-14 17:07:18 -03:00
committed by GitHub
74 changed files with 1982 additions and 703 deletions
+2
View File
@@ -36,6 +36,7 @@ import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
import vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified';
import photoRoutes from './routes/photos';
import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey';
@@ -266,6 +267,7 @@ export function createApp(): express.Application {
app.use('/api/journeys', journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
+143
View File
@@ -1435,6 +1435,149 @@ function runMigrations(db: Database.Database): void {
() => {
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1"); } catch {}
},
// Migration: Unified Photo Provider Abstraction Layer (#584)
// Central trek_photos registry; trip_photos + journey_photos reference via photo_id
() => {
// 1. Create the central photo registry
db.exec(`
CREATE TABLE IF NOT EXISTS trek_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
asset_id TEXT,
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
file_path TEXT,
thumbnail_path TEXT,
width INTEGER,
height INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL');
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)');
// 2. Migrate trip_photos → trek_photos + photo_id FK
const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get();
if (tripPhotosExists) {
// Detect schema variant: old (immich_asset_id) vs new (asset_id + provider)
const tpCols = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
const tpColNames = new Set(tpCols.map(c => c.name));
const hasProvider = tpColNames.has('provider');
const assetCol = tpColNames.has('asset_id') ? 'asset_id' : (tpColNames.has('immich_asset_id') ? 'immich_asset_id' : null);
const hasAlbumLink = tpColNames.has('album_link_id');
if (assetCol) {
const providerExpr = hasProvider ? 'provider' : "'immich'";
// Qualified alias needed in JOIN context where both trip_photos and trek_photos have provider
const providerJoinExpr = hasProvider ? 'tp.provider' : "'immich'";
const sharedExpr = tpColNames.has('shared') ? 'shared' : '1';
const addedAtExpr = tpColNames.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
const albumLinkExpr = hasAlbumLink ? 'album_link_id' : 'NULL';
// Insert existing trip photo references into trek_photos
db.exec(`
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at)
SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr}
FROM trip_photos
WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != ''
`);
// Recreate trip_photos with photo_id FK
db.exec(`
CREATE TABLE trip_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
shared INTEGER NOT NULL DEFAULT 1,
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, photo_id)
)
`);
db.exec(`
INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at)
SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr}
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id
`);
} else {
// No asset column at all — just recreate empty
db.exec(`
CREATE TABLE trip_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
shared INTEGER NOT NULL DEFAULT 1,
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, photo_id)
)
`);
}
db.exec('DROP TABLE trip_photos');
db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos');
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)');
}
// 3. Migrate journey_photos → trek_photos + photo_id FK
const journeyPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'").get();
if (journeyPhotosExists) {
// Insert provider-based journey photos into trek_photos
db.exec(`
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at)
SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at
FROM journey_photos
WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != ''
`);
// Insert local journey photos into trek_photos (each is unique)
db.exec(`
INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at)
SELECT 'local', file_path, thumbnail_path, width, height, created_at
FROM journey_photos
WHERE provider = 'local' AND file_path IS NOT NULL
`);
// Recreate journey_photos with photo_id FK
db.exec(`
CREATE TABLE journey_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
caption TEXT,
sort_order INTEGER DEFAULT 0,
shared INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
)
`);
// Migrate provider photos
db.exec(`
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id
WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL
`);
// Migrate local photos (match by file_path)
db.exec(`
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path
WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL
`);
db.exec('DROP TABLE journey_photos');
db.exec('ALTER TABLE journey_photos_new RENAME TO journey_photos');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)');
}
},
// Migration 99: hide_skeletons per-user setting on journey_contributors
() => {
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {}
},
];
if (currentVersion < migrations.length) {
+25 -4
View File
@@ -64,14 +64,14 @@ router.get('/available-trips', authenticate, (req: Request, res: Response) => {
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {});
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {}, req.headers['x-socket-id'] as string);
if (!result) return res.status(404).json({ error: 'Entry not found' });
res.json(result);
});
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id)) {
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id, req.headers['x-socket-id'] as string)) {
return res.status(404).json({ error: 'Entry not found' });
}
res.json({ success: true });
@@ -115,7 +115,19 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, caption } = req.body || {};
const { provider, asset_id, asset_ids, caption } = req.body || {};
// Batch mode: { provider, asset_ids: string[] }
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
}
// Single mode (backward compat)
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
@@ -233,7 +245,7 @@ router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { entry_date } = req.body || {};
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body);
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body, req.headers['x-socket-id'] as string);
if (!entry) return res.status(404).json({ error: 'Journey not found' });
res.status(201).json(entry);
});
@@ -267,6 +279,15 @@ router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Res
res.json({ success: true });
});
// ── User Preferences ─────────────────────────────────────────────────────
router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body);
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.json(result);
});
// ── Share Link ────────────────────────────────────────────────────────────
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
+12 -6
View File
@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express';
import { getPublicJourney, validateShareTokenForAsset } from '../services/journeyShareService';
import { getPublicJourney, validateShareTokenForAsset, validateShareTokenForPhoto } from '../services/journeyShareService';
import { streamPhoto } from '../services/memories/photoResolverService';
import { streamImmichAsset } from '../services/memories/immichService';
import path from 'node:path';
import fs from 'node:fs';
@@ -12,16 +13,23 @@ router.get('/:token', (req: Request, res: Response) => {
res.json(data);
});
// Public photo proxy — validates share token instead of auth
// Unified public photo proxy — uses trek_photo_id
router.get('/:token/photos/:photoId/:kind', async (req: Request, res: Response) => {
const { token, photoId, kind } = req.params;
const valid = validateShareTokenForPhoto(token, Number(photoId));
if (!valid) return res.status(404).json({ error: 'Not found' });
await streamPhoto(res, valid.ownerId, Number(photoId), kind === 'thumbnail' ? 'thumbnail' : 'original');
});
// Legacy public photo proxy — validates share token instead of auth
router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
const { token, provider, assetId, ownerId, kind } = req.params;
// Validate token and that this asset belongs to the shared journey
const valid = validateShareTokenForAsset(token, assetId);
if (!valid) return res.status(404).json({ error: 'Not found' });
if (provider === 'local') {
// Local file — assetId is the file_path
const filePath = path.join(__dirname, '../../uploads/journey', assetId);
const resolved = path.resolve(filePath);
const uploadsDir = path.resolve(__dirname, '../../uploads');
@@ -32,12 +40,10 @@ router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Reques
return res.sendFile(resolved);
}
// Immich/Synology — proxy through
const effectiveOwnerId = valid.ownerId || Number(ownerId);
if (provider === 'immich') {
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
} else {
// Synology or other providers — try dynamic import
try {
const { streamSynologyAsset } = await import('../services/memories/synologyService');
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
+18 -4
View File
@@ -13,6 +13,7 @@ import {
searchPhotos,
streamImmichAsset,
listAlbums,
getAlbumPhotos,
syncAlbumAssets,
getAssetInfo,
isValidAssetId,
@@ -59,10 +60,16 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { from, to } = req.body;
const result = await searchPhotos(authReq.user.id, from, to);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ assets: result.assets });
const { from, to, size } = req.body;
const pageSize = Math.min(Number(size) || 50, 200);
const allAssets: any[] = [];
for (let page = 1; page <= 20; page++) {
const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.assets) allAssets.push(...result.assets);
if (!result.hasMore) break;
}
res.json({ assets: allAssets });
});
// ── Asset Details ──────────────────────────────────────────────────────────
@@ -113,6 +120,13 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
res.json({ albums: result.albums });
});
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await getAlbumPhotos(authReq.user.id, req.params.albumId);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ assets: result.assets });
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
+12 -2
View File
@@ -7,6 +7,7 @@ import {
getSynologyStatus,
testSynologyConnection,
listSynologyAlbums,
getSynologyAlbumPhotos,
syncSynologyAlbumLink,
searchSynologyPhotos,
getSynologyAssetInfo,
@@ -77,6 +78,11 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
});
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
@@ -90,8 +96,12 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
const body = req.body as Record<string, unknown>;
const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to);
const offset = _parseNumberBodyField(body.offset, 0);
const limit = _parseNumberBodyField(body.limit, 100);
let offset = _parseNumberBodyField(body.offset, 0);
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if(page > 0) offset = page*limit;
if(size > 0) limit = size;
handleServiceResult(res, await searchSynologyPhotos(
authReq.user.id,
+2 -3
View File
@@ -55,8 +55,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
const result = await setTripPhotoSharing(
tripId,
authReq.user.id,
req.body?.provider,
req.body?.asset_id,
Number(req.body?.photo_id),
req.body?.shared,
);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
@@ -66,7 +65,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id));
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
+47
View File
@@ -0,0 +1,47 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { streamPhoto, getPhotoInfo, resolveTrekPhoto } from '../services/memories/photoResolverService';
import { canAccessTrekPhoto } from '../services/memories/helpersService';
const router = express.Router();
router.get('/:id/thumbnail', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photoId = Number(req.params.id);
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
return res.status(403).json({ error: 'Forbidden' });
}
await streamPhoto(res, authReq.user.id, photoId, 'thumbnail');
});
router.get('/:id/original', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photoId = Number(req.params.id);
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
return res.status(403).json({ error: 'Forbidden' });
}
await streamPhoto(res, authReq.user.id, photoId, 'original');
});
router.get('/:id/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photoId = Number(req.params.id);
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
return res.status(403).json({ error: 'Forbidden' });
}
const result = await getPhotoInfo(authReq.user.id, photoId);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json(result.data);
});
export default router;
+44 -19
View File
@@ -171,12 +171,23 @@ export const CONTINENT_MAP: Record<string, string> = {
// ── Geocoding helpers ───────────────────────────────────────────────────────
let lastNominatimCall = 0;
// Shared throttle: enforces ≥1.1s between any Nominatim request, across all callers.
async function throttleNominatim() {
const elapsed = Date.now() - lastNominatimCall;
if (elapsed < 1100) await new Promise(r => setTimeout(r, 1100 - elapsed));
lastNominatimCall = Date.now();
}
export async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
const key = roundKey(lat, lng);
if (geocodeCache.has(key)) return geocodeCache.get(key)!;
await throttleNominatim();
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, {
headers: { 'User-Agent': 'TREK Travel Planner' },
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
const data = await res.json() as { address?: { country_code?: string } };
@@ -215,15 +226,15 @@ export function getCountryFromAddress(address: string | null): string | null {
return null;
}
// ── Resolve a place to a country code (address -> geocode -> bbox) ──────────
// ── Resolve a place to a country code (address -> bbox -> geocode) ──────────
async function resolveCountryCode(place: Place): Promise<string | null> {
let code = getCountryFromAddress(place.address);
if (!code && place.lat && place.lng) {
code = await reverseGeocodeCountry(place.lat, place.lng);
code = getCountryFromCoords(place.lat, place.lng);
}
if (!code && place.lat && place.lng) {
code = getCountryFromCoords(place.lat, place.lng);
code = await reverseGeocodeCountry(place.lat, place.lng);
}
return code;
}
@@ -453,15 +464,22 @@ export function unmarkRegionVisited(userId: number, regionCode: string): void {
interface RegionInfo { country_code: string; region_code: string; region_name: string }
// Tracks place IDs currently being geocoded in the background to prevent duplicate enqueuing.
const geocodingInFlight = new Set<number>();
const regionCache = new Map<string, RegionInfo | null>();
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
const key = roundKey(lat, lng);
if (regionCache.has(key)) return regionCache.get(key)!;
await throttleNominatim();
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
{
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
signal: AbortSignal.timeout(10_000),
}
);
if (!res.ok) return null;
const data = await res.json() as { address?: Record<string, string> };
@@ -498,20 +516,27 @@ export async function getVisitedRegions(userId: number): Promise<{ regions: Reco
: [];
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
// Resolve uncached places (rate-limited to avoid hammering Nominatim)
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
for (const place of uncached) {
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
if (info) {
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
cachedMap.set(place.id, { place_id: place.id, ...info });
}
// Nominatim rate limit: 1 req/sec
if (uncached.indexOf(place) < uncached.length - 1) {
await new Promise(r => setTimeout(r, 1100));
}
// Kick off background geocoding for uncached places; return cached data immediately.
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id));
if (uncached.length > 0) {
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
for (const p of uncached) geocodingInFlight.add(p.id);
void (async () => {
try {
for (const place of uncached) {
try {
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
} catch {
// individual failure — continue with remaining places
} finally {
geocodingInFlight.delete(place.id);
}
}
} catch {
for (const p of uncached) geocodingInFlight.delete(p.id);
}
})();
}
// Group by country → regions with place counts
+1 -1
View File
@@ -117,7 +117,7 @@ export function listBackups(): BackupInfo[] {
filename,
size: stat.size,
sizeText: formatSize(stat.size),
created_at: stat.birthtime.toISOString(),
created_at: stat.mtime.toISOString(),
};
})
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+80 -37
View File
@@ -1,12 +1,20 @@
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();
}
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeUserId?: number) {
// 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>, excludeSocketId?: string | number) {
const contributors = db.prepare(
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
).all(journeyId) as { user_id: number }[];
@@ -16,8 +24,7 @@ function broadcastJourneyEvent(journeyId: number, event: string, data: Record<st
if (owner) userIds.add(owner.user_id);
for (const uid of userIds) {
if (uid === excludeUserId) continue;
broadcastToUser(uid, { type: event, journeyId, ...data });
broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId);
}
}
@@ -105,7 +112,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
@@ -154,12 +161,17 @@ export function getJourneyFull(journeyId: number, userId: number) {
const photoCount = photos.length;
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journeyId, userId) as { hide_skeletons: number } | undefined;
return {
...journey,
entries: enrichedEntries,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
hide_skeletons: !!(userPrefs?.hide_skeletons),
};
}
@@ -190,6 +202,19 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
}
export function updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) {
if (!canAccessJourney(journeyId, userId)) return null;
if (data.hide_skeletons !== undefined) {
db.prepare(
'UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?'
).run(data.hide_skeletons ? 1 : 0, journeyId, userId);
}
const row = db.prepare(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journeyId, userId) as { hide_skeletons: number };
return { hide_skeletons: !!row.hide_skeletons };
}
export function deleteJourney(journeyId: number, userId: number): boolean {
if (!isOwner(journeyId, userId)) return false;
db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
@@ -210,7 +235,7 @@ export function addTripToJourney(journeyId: number, tripId: number, userId: numb
syncTripPlaces(journeyId, tripId, userId);
// import existing trip photos (Immich/Synology) with sharing settings
syncTripPhotos(journeyId, tripId);
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId);
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
return true;
}
@@ -253,6 +278,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
for (const place of places) {
if (existingPlaceIds.has(place.id)) continue;
existingPlaceIds.add(place.id);
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
@@ -272,8 +298,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 +311,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 +322,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 +449,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[]> = {};
@@ -457,7 +482,7 @@ export function createEntry(journeyId: number, userId: number, data: {
tags?: string[];
pros_cons?: { pros: string[]; cons: string[] };
visibility?: string;
}): JourneyEntry | null {
}, sid?: string): JourneyEntry | null {
if (!canEdit(journeyId, userId)) return null;
const now = ts();
@@ -491,7 +516,7 @@ export function createEntry(journeyId: number, userId: number, data: {
);
const created = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyEntry;
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, userId);
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid);
return created;
}
@@ -510,7 +535,7 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
pros_cons: { pros: string[]; cons: string[] };
visibility: string;
sort_order: number;
}>): JourneyEntry | null {
}>, sid?: string): JourneyEntry | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
@@ -549,18 +574,31 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id);
const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry;
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, userId);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid);
return updated;
}
export function deleteEntry(entryId: number, userId: number): boolean {
export function deleteEntry(entryId: number, userId: number, sid?: string): boolean {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false;
// delete photos along with the entry — no more orphan Gallery entries
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
// Revert filled entry back to skeleton instead of deleting
db.prepare(`
UPDATE journey_entries
SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL,
visibility = 'private', updated_at = ?
WHERE id = ?
`).run(ts(), entryId);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid);
} else {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
}
// clean up any empty Gallery entries in this journey
db.prepare(`
@@ -568,7 +606,6 @@ export function deleteEntry(entryId: number, userId: number): boolean {
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
`).run(entry.journey_id);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId);
return true;
}
@@ -579,15 +616,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 +633,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 +655,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 +674,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 +701,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;
+10 -6
View File
@@ -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
+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
+68 -34
View File
@@ -149,44 +149,36 @@ export async function browseTimeline(
export async function searchPhotos(
userId: number,
from?: string,
to?: string
): Promise<{ assets?: any[]; error?: string; status?: number }> {
to?: string,
page: number = 1,
size: number = 50,
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
// Paginate through all results (Immich limits per-page to 1000)
const allAssets: any[] = [];
let page = 1;
const pageSize = 1000;
while (true) {
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
size: pageSize,
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Search failed', status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
allAssets.push(...items);
if (items.length < pageSize) break; // Last page
page++;
if (page > 20) break; // Safety limit (20k photos max)
}
const assets = allAssets.map((a: any) => ({
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
size,
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Search failed', status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
const assets = items.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
}));
return { assets };
return { assets, hasMore: items.length >= size };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
@@ -266,18 +258,34 @@ export async function listAlbums(
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await safeFetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
// Fetch both owned and shared albums
const [ownResp, sharedResp] = await Promise.all([
safeFetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
}),
safeFetch(`${creds.immich_url}/api/albums?shared=true`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
}),
]);
if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status };
const ownAlbums = await ownResp.json() as any[];
const sharedAlbums = sharedResp.ok ? await sharedResp.json() as any[] : [];
const seenIds = new Set<string>();
const allAlbums = [...ownAlbums, ...sharedAlbums].filter((a: any) => {
if (seenIds.has(a.id)) return false;
seenIds.add(a.id);
return true;
});
if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status };
const albums = (await resp.json() as any[]).map((a: any) => ({
const albums = allAlbums.map((a: any) => ({
id: a.id,
albumName: a.albumName,
assetCount: a.assetCount || 0,
startDate: a.startDate,
endDate: a.endDate,
albumThumbnailAssetId: a.albumThumbnailAssetId,
shared: a.shared || a.sharedUsers?.length > 0,
}));
return { albums };
} catch {
@@ -285,6 +293,32 @@ export async function listAlbums(
}
}
export async function getAlbumPhotos(
userId: number,
albumId: string,
): Promise<{ assets?: any[]; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE').map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
}));
return { assets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
export function listAlbumLinks(tripId: string) {
return db.prepare(`
SELECT tal.*, u.username
@@ -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);
}
@@ -452,6 +452,36 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
}
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> {
const allItems: SynologyPhotoItem[] = [];
const pageSize = 1000;
let offset = 0;
while (true) {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Item',
method: 'list',
version: 1,
album_id: Number(albumId),
offset,
limit: pageSize,
additional: ['thumbnail'],
});
if (!result.success) return result as ServiceResult<AssetsList>;
const items = result.data.list || [];
allItems.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
const assets = allItems.map(item => ({
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
})).filter(a => a.id);
return success({ assets, total: assets.length, hasMore: false });
}
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
@@ -555,7 +585,6 @@ export async function streamSynologyAsset(
targetUserId: number,
photoId: string,
kind: 'thumbnail' | 'original',
size?: string,
): Promise<void> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) {
@@ -579,6 +608,8 @@ export async function streamSynologyAsset(
return;
}
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
const params = kind === 'thumbnail'
? new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
@@ -587,7 +618,7 @@ export async function streamSynologyAsset(
mode: 'download',
id: parsedId.id,
type: 'unit',
size: size,
size: 'sm',
cache_key: parsedId.cacheKey,
_sid: sid.data,
})
+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);
+17 -2
View File
@@ -399,9 +399,24 @@ export async function sendWebhook(url: string, payload: { event: string; title:
}
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
if (!getSmtpConfig()) return { success: false, error: 'SMTP not configured' };
try {
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
return sent ? { success: true } : { success: false, error: 'SMTP not configured' };
const config = getSmtpConfig()!;
const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true';
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
});
await transporter.sendMail({
from: config.from,
to,
subject: 'TREK — Test Notification',
text: 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.',
});
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
+8 -2
View File
@@ -197,7 +197,10 @@ export async function getWeather(
if (diffDays > -1) {
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
const refYear = targetDate.getFullYear() - 1;
let refYear = targetDate.getFullYear() - 1;
// Archive API only has data up to yesterday — go back further if needed
const yesterday = new Date(now.getTime() - 86400000);
if (new Date(refYear, month - 1, day + 2) > yesterday) refYear--;
const startDate = new Date(refYear, month - 1, day - 2);
const endDate = new Date(refYear, month - 1, day + 2);
const startStr = startDate.toISOString().slice(0, 10);
@@ -299,7 +302,10 @@ export async function getDetailedWeather(
// Climate / archive path (> 16 days out)
if (diffDays > 16) {
const refYear = targetDate.getFullYear() - 1;
let refYear = targetDate.getFullYear() - 1;
// Archive API only has data up to yesterday — go back further if needed
const yesterday = new Date(now.getTime() - 86400000);
if (new Date(refYear, targetDate.getMonth(), targetDate.getDate()) > yesterday) refYear--;
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
+19 -5
View File
@@ -339,20 +339,34 @@ export interface JourneyEntry {
updated_at: number;
}
export interface JourneyPhoto {
export interface TrekPhoto {
id: number;
entry_id: number;
provider: 'local' | 'immich' | 'synologyphotos';
provider: string;
asset_id?: string | null;
owner_id?: number | null;
file_path?: string | null;
thumbnail_path?: string | null;
caption?: string | null;
sort_order: number;
width?: number | null;
height?: number | null;
created_at: string;
}
export interface JourneyPhoto {
id: number;
entry_id: number;
photo_id: number;
caption?: string | null;
sort_order: number;
shared: number;
created_at: number;
// Joined from trek_photos for API responses
provider?: string;
asset_id?: string | null;
owner_id?: number | null;
file_path?: string | null;
thumbnail_path?: string | null;
width?: number | null;
height?: number | null;
}
export interface JourneyTrip {