diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx
index d8d167d..da9873e 100644
--- a/client/src/components/Memories/MemoriesPanel.tsx
+++ b/client/src/components/Memories/MemoriesPanel.tsx
@@ -89,7 +89,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const loadAlbumLinks = async () => {
try {
- const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`)
+ const res = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`)
setAlbumLinks(res.data.links || [])
} catch { setAlbumLinks([]) }
}
@@ -98,7 +98,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!provider) return
setAlbumsLoading(true)
try {
- const res = await apiClient.get(`/integrations/${provider}/albums`)
+ const res = await apiClient.get(`/integrations/memories/${provider}/albums`)
setAlbums(res.data.albums || [])
} catch {
setAlbums([])
@@ -120,7 +120,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
try {
- await apiClient.post(`/integrations/memories/trips/${tripId}/album-links`, {
+ await apiClient.post(`/integrations/memories/unified/trips/${tripId}/album-links`, {
album_id: albumId,
album_name: albumName,
provider: selectedProvider,
@@ -128,7 +128,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setShowAlbumPicker(false)
await loadAlbumLinks()
// Auto-sync after linking
- const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`)
+ const linksRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`)
const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider)
if (newLink) await syncAlbum(newLink.id)
} catch { toast.error(t('memories.error.linkAlbum')) }
@@ -136,7 +136,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const unlinkAlbum = async (linkId: number) => {
try {
- await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`)
+ await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/album-links/${linkId}`)
loadAlbumLinks()
} catch { toast.error(t('memories.error.unlinkAlbum')) }
}
@@ -146,7 +146,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!targetProvider) return
setSyncing(linkId)
try {
- await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`)
+ await apiClient.post(`/integrations/memories/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`)
await loadAlbumLinks()
await loadPhotos()
} catch { toast.error(t('memories.error.syncAlbum')) }
@@ -175,7 +175,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const loadPhotos = async () => {
try {
- const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`)
+ const photosRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/photos`)
setTripPhotos(photosRes.data.photos || [])
} catch {
setTripPhotos([])
@@ -257,7 +257,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setPickerPhotos([])
return
}
- const res = await apiClient.post(`/integrations/${provider.id}/search`, {
+ const res = await apiClient.post(`/integrations/memories/${provider.id}/search`, {
from: useDate && startDate ? startDate : undefined,
to: useDate && endDate ? endDate : undefined,
})
@@ -296,7 +296,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
groupedByProvider.set(provider, list)
}
- await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, {
+ await apiClient.post(`/integrations/memories/unified/trips/${tripId}/photos`, {
selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })),
shared: true,
})
@@ -310,7 +310,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const removePhoto = async (photo: TripPhoto) => {
try {
- await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, {
+ await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/photos`, {
data: {
asset_id: photo.asset_id,
provider: photo.provider,
@@ -324,7 +324,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const toggleSharing = async (photo: TripPhoto, shared: boolean) => {
try {
- await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, {
+ await apiClient.put(`/integrations/memories/unified/trips/${tripId}/photos/sharing`, {
shared,
asset_id: photo.asset_id,
provider: photo.provider,
@@ -338,7 +338,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// ── Helpers ───────────────────────────────────────────────────────────────
const thumbnailBaseUrl = (photo: TripPhoto) =>
- `/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail`
+ `/api/integrations/memories/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail`
const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}`
@@ -598,7 +598,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
outlineOffset: -3,
}}>
-
{isSelected && (
setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
}}>
diff --git a/server/src/app.ts b/server/src/app.ts
index 13b7668..60ddcb8 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -33,9 +33,7 @@ import backupRoutes from './routes/backup';
import oidcRoutes from './routes/oidc';
import vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas';
-import immichRoutes from './routes/immich';
-import synologyRoutes from './routes/synology';
-import memoriesRoutes from './routes/memories';
+import memoriesRoutes from './routes/memories/unified';
import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share';
import { mcpHandler } from './mcp';
@@ -255,8 +253,6 @@ export function createApp(): express.Application {
app.use('/api/addons/vacay', vacayRoutes);
app.use('/api/addons/atlas', atlasRoutes);
app.use('/api/integrations/memories', memoriesRoutes);
- app.use('/api/integrations/immich', immichRoutes);
- app.use('/api/integrations/synologyphotos', synologyRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
diff --git a/server/src/routes/immich.ts b/server/src/routes/memories/immich.ts
similarity index 93%
rename from server/src/routes/immich.ts
rename to server/src/routes/memories/immich.ts
index 012d7ef..af9b4d0 100644
--- a/server/src/routes/immich.ts
+++ b/server/src/routes/memories/immich.ts
@@ -1,10 +1,10 @@
import express, { Request, Response, NextFunction } from 'express';
-import { db, canAccessTrip } from '../db/database';
-import { authenticate } from '../middleware/auth';
-import { broadcast } from '../websocket';
-import { AuthRequest } from '../types';
-import { consumeEphemeralToken } from '../services/ephemeralTokens';
-import { getClientIp } from '../services/auditLog';
+import { db, canAccessTrip } from '../../db/database';
+import { authenticate } from '../../middleware/auth';
+import { broadcast } from '../../websocket';
+import { AuthRequest } from '../../types';
+import { consumeEphemeralToken } from '../../services/ephemeralTokens';
+import { getClientIp } from '../../services/auditLog';
import {
getConnectionSettings,
saveImmichSettings,
@@ -14,12 +14,11 @@ import {
searchPhotos,
proxyThumbnail,
proxyOriginal,
- isValidAssetId,
listAlbums,
syncAlbumAssets,
getAssetInfo,
-} from '../services/immichService';
-import { canAccessUserPhoto } from '../services/memoriesService';
+} from '../../services/memories/immichService';
+import { canAccessUserPhoto } from '../../services/memories/helpersService';
const router = express.Router();
diff --git a/server/src/routes/synology.ts b/server/src/routes/memories/synology.ts
similarity index 96%
rename from server/src/routes/synology.ts
rename to server/src/routes/memories/synology.ts
index 838a3de..cd3512d 100644
--- a/server/src/routes/synology.ts
+++ b/server/src/routes/memories/synology.ts
@@ -1,7 +1,7 @@
import express, { Request, Response } from 'express';
-import { authenticate } from '../middleware/auth';
-import { broadcast } from '../websocket';
-import { AuthRequest } from '../types';
+import { authenticate } from '../../middleware/auth';
+import { broadcast } from '../../websocket';
+import { AuthRequest } from '../../types';
import {
getSynologySettings,
updateSynologySettings,
@@ -15,8 +15,8 @@ import {
streamSynologyAsset,
handleSynologyError,
SynologyServiceError,
-} from '../services/synologyService';
-import { canAccessUserPhoto } from '../services/memoriesService';
+} from '../../services/memories/synologyService';
+import { canAccessUserPhoto } from '../../services/memories/helpersService';
const router = express.Router();
diff --git a/server/src/routes/memories.ts b/server/src/routes/memories/unified.ts
similarity index 74%
rename from server/src/routes/memories.ts
rename to server/src/routes/memories/unified.ts
index e60b9e1..3e4561d 100644
--- a/server/src/routes/memories.ts
+++ b/server/src/routes/memories/unified.ts
@@ -1,6 +1,6 @@
import express, { Request, Response } from 'express';
-import { authenticate } from '../middleware/auth';
-import { AuthRequest } from '../types';
+import { authenticate } from '../../middleware/auth';
+import { AuthRequest } from '../../types';
import {
listTripPhotos,
listTripAlbumLinks,
@@ -9,14 +9,19 @@ import {
addTripPhotos,
removeTripPhoto,
setTripPhotoSharing,
-} from '../services/memoriesService';
+} from '../../services/memories/unifiedService';
+import immichRouter from './immich';
+import synologyRouter from './synology';
const router = express.Router();
+router.use('/immich', immichRouter);
+router.use('/synologyphotos', synologyRouter);
+
//------------------------------------------------
// routes for managing photos linked to trip
-router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
+router.get('/unified/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = listTripPhotos(tripId, authReq.user.id);
@@ -24,7 +29,7 @@ router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
res.json({ photos: result.data });
});
-router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
+router.post('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const sid = req.headers['x-socket-id'] as string;
@@ -42,7 +47,7 @@ router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Res
res.json({ success: true, added: result.data.added });
});
-router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => {
+router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = await setTripPhotoSharing(
@@ -56,7 +61,7 @@ router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, r
res.json({ success: true });
});
-router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
+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);
@@ -67,7 +72,7 @@ router.delete('/trips/:tripId/photos', authenticate, async (req: Request, res: R
//------------------------------
// routes for managing album links
-router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
+router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = listTripAlbumLinks(tripId, authReq.user.id);
@@ -75,7 +80,7 @@ router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Respo
res.json({ links: result.data });
});
-router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
+router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name);
@@ -83,7 +88,7 @@ router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res
res.json({ success: true });
});
-router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => {
+router.delete('/unified/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
const result = removeAlbumLink(tripId, linkId, authReq.user.id);
@@ -91,4 +96,7 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, async (req: Re
res.json({ success: true });
});
+
+
+
export default router;
diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts
new file mode 100644
index 0000000..51c899a
--- /dev/null
+++ b/server/src/services/memories/helpersService.ts
@@ -0,0 +1,79 @@
+import { canAccessTrip, db } from "../../db/database";
+
+// helpers for handling return types
+
+type ServiceError = { success: false; error: { message: string; status: number } };
+export type ServiceResult
= { success: true; data: T } | ServiceError;
+
+
+export function fail(error: string, status: number): ServiceError {
+ return { success: false, error: { message: error, status } };
+}
+
+
+export function success(data: T): ServiceResult {
+ return { success: true, data: data };
+}
+
+
+export function mapDbError(error: unknown, fallbackMessage: string): ServiceError {
+ if (error instanceof Error && /unique|constraint/i.test(error.message)) {
+ return fail('Resource already exists', 409);
+ }
+ return fail(fallbackMessage, 500);
+}
+
+
+// ----------------------------------------------
+// types used across memories services
+export type Selection = {
+ provider: string;
+ asset_ids: string[];
+};
+
+
+//-----------------------------------------------
+//access check helper
+
+export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
+ if (requestingUserId === ownerUserId) {
+ return true;
+ }
+ const sharedAsset = db.prepare(`
+ SELECT 1
+ FROM trip_photos
+ WHERE user_id = ?
+ AND asset_id = ?
+ AND provider = ?
+ AND trip_id = ?
+ AND shared = 1
+ LIMIT 1
+ `).get(ownerUserId, assetId, provider, tripId);
+
+ if (!sharedAsset) {
+ return false;
+ }
+ return !!canAccessTrip(tripId, requestingUserId);
+}
+
+
+// ----------------------------------------------
+//helpers for album link syncing
+
+export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult {
+ const access = canAccessTrip(tripId, userId);
+ if (!access) return fail('Trip not found or access denied', 404);
+
+ try {
+ const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
+ .get(linkId, tripId, userId) as { album_id: string } | null;
+
+ return row ? success(row.album_id) : fail('Album link not found', 404);
+ } catch {
+ return fail('Failed to retrieve album link', 500);
+ }
+}
+
+export function updateSyncTimeForAlbumLink(linkId: string): void {
+ db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
+}
diff --git a/server/src/services/immichService.ts b/server/src/services/memories/immichService.ts
similarity index 97%
rename from server/src/services/immichService.ts
rename to server/src/services/memories/immichService.ts
index d4a353b..47a9895 100644
--- a/server/src/services/immichService.ts
+++ b/server/src/services/memories/immichService.ts
@@ -1,9 +1,9 @@
-import { db } from '../db/database';
-import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
-import { checkSsrf } from '../utils/ssrfGuard';
-import { writeAudit } from './auditLog';
-import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService';
-import { error } from 'node:console';
+import { db } from '../../db/database';
+import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
+import { checkSsrf } from '../../utils/ssrfGuard';
+import { writeAudit } from '../auditLog';
+import { addTripPhotos} from './unifiedService';
+import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService';
// ── Credentials ────────────────────────────────────────────────────────────
diff --git a/server/src/services/synologyService.ts b/server/src/services/memories/synologyService.ts
similarity index 98%
rename from server/src/services/synologyService.ts
rename to server/src/services/memories/synologyService.ts
index 4f66fbd..c9c8b57 100644
--- a/server/src/services/synologyService.ts
+++ b/server/src/services/memories/synologyService.ts
@@ -1,12 +1,11 @@
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { Response as ExpressResponse } from 'express';
-import { db } from '../db/database';
-import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
-import { checkSsrf } from '../utils/ssrfGuard';
-import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService';
-import { error } from 'node:console';
-import { th } from 'zod/locales';
+import { db } from '../../db/database';
+import { decrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto';
+import { checkSsrf } from '../../utils/ssrfGuard';
+import { addTripPhotos} from './unifiedService';
+import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService';
const SYNOLOGY_API_TIMEOUT_MS = 30000;
const SYNOLOGY_PROVIDER = 'synologyphotos';
diff --git a/server/src/services/memoriesService.ts b/server/src/services/memories/unifiedService.ts
similarity index 75%
rename from server/src/services/memoriesService.ts
rename to server/src/services/memories/unifiedService.ts
index 03ac465..35d47eb 100644
--- a/server/src/services/memoriesService.ts
+++ b/server/src/services/memories/unifiedService.ts
@@ -1,49 +1,15 @@
-import { db, canAccessTrip } from '../db/database';
-import { notifyTripMembers } from './notifications';
-import { broadcast } from '../websocket';
+import { db, canAccessTrip } from '../../db/database';
+import { notifyTripMembers } from '../notifications';
+import { broadcast } from '../../websocket';
+import {
+ ServiceResult,
+ fail,
+ success,
+ mapDbError,
+ Selection,
+} from './helpersService';
-type ServiceError = { success: false; error: { message: string; status: number } };
-type ServiceResult = { success: true; data: T } | ServiceError;
-
-function fail(error: string, status: number): ServiceError {
- return { success: false, error: { message: error, status }};
-}
-
-function success(data: T): ServiceResult {
- return { success: true, data: data };
-}
-
-function mapDbError(error: unknown, fallbackMessage: string): ServiceError {
- if (error instanceof Error && /unique|constraint/i.test(error.message)) {
- return fail('Resource already exists', 409);
- }
- return fail(fallbackMessage, 500);
-}
-
-//-----------------------------------------------
-//access check helper
-
-export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
- if (requestingUserId === ownerUserId) {
- return true;
- }
- const sharedAsset = db.prepare(`
- SELECT 1
- FROM trip_photos
- WHERE user_id = ?
- AND asset_id = ?
- AND provider = ?
- AND trip_id = ?
- AND shared = 1
- LIMIT 1
- `).get(ownerUserId, assetId, provider, tripId);
-
- if (!sharedAsset) {
- return false;
- }
- return !!canAccessTrip(tripId, requestingUserId);
-}
export function listTripPhotos(tripId: string, userId: number): ServiceResult {
const access = canAccessTrip(tripId, userId);
@@ -108,11 +74,6 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId
return result.changes > 0;
}
-export type Selection = {
- provider: string;
- asset_ids: string[];
-};
-
export async function addTripPhotos(
tripId: string,
userId: number,
@@ -181,7 +142,6 @@ export async function setTripPhotoSharing(
}
}
-
export function removeTripPhoto(
tripId: string,
userId: number,
@@ -262,25 +222,6 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number):
}
}
-//helpers for album link syncing
-
-export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult {
- const access = canAccessTrip(tripId, userId);
- if (!access) return fail('Trip not found or access denied', 404);
-
- try {
- const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
- .get(linkId, tripId, userId) as { album_id: string } | null;
-
- return row ? success(row.album_id) : fail('Album link not found', 404);
- } catch {
- return fail('Failed to retrieve album link', 500);
- }
-}
-
-export function updateSyncTimeForAlbumLink(linkId: string): void {
- db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
-}
//-----------------------------------------------
// notifications helper
@@ -306,5 +247,3 @@ async function _notifySharedTripPhotos(
return fail('Failed to send notifications', 500);
}
}
-
-