change in hadnling return values from unified service

This commit is contained in:
Marek Maslowski
2026-04-04 13:36:12 +02:00
parent 68f0d399ca
commit 504713d920
4 changed files with 300 additions and 274 deletions

View File

@@ -1,6 +1,5 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest } from '../types';
import {
listTripPhotos,
@@ -10,94 +9,85 @@ import {
addTripPhotos,
removeTripPhoto,
setTripPhotoSharing,
notifySharedTripPhotos,
normalizeSelections,
} from '../services/memoriesService';
const router = express.Router();
//------------------------------------------------
// routes for managing photos linked to trip
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = listTripPhotos(tripId, authReq.user.id);
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json({ photos: result.photos });
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ photos: result.data });
});
router.get('/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);
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json({ links: result.links });
});
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
const result = removeAlbumLink(tripId, linkId, authReq.user.id);
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json({ success: true });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
router.post('/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;
const selections = normalizeSelections(req.body?.selections, req.body?.provider, req.body?.asset_ids);
const shared = req.body?.shared === undefined ? true : !!req.body?.shared;
const result = addTripPhotos(
const result = await addTripPhotos(
tripId,
authReq.user.id,
shared,
selections,
req.body?.selections || [],
sid,
);
if ('error' in result) return res.status(result.status).json({ error: result.error });
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true, added: result.added });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
if (result.shared && result.added > 0) {
void notifySharedTripPhotos(
tripId,
authReq.user.id,
authReq.user.username || authReq.user.email,
result.added,
).catch(() => {});
}
res.json({ success: true, added: result.data.added });
});
router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
router.put('/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
if ('error' in result) return res.status(result.status).json({ error: result.error });
res.json({ success: true });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = setTripPhotoSharing(
const result = await setTripPhotoSharing(
tripId,
authReq.user.id,
req.body?.provider,
req.body?.asset_id,
req.body?.shared,
);
if ('error' in result) return res.status(result.status).json({ error: result.error });
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
router.delete('/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);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
//------------------------------
// routes for managing album links
router.get('/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);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ links: result.data });
});
router.post('/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);
if ('error' in result) return res.status(result.status).json({ error: result.error });
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
router.delete('/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);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});

View File

@@ -3,6 +3,7 @@ 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';
// ── Credentials ────────────────────────────────────────────────────────────
@@ -314,14 +315,14 @@ export async function syncAlbumAssets(
linkId: string,
userId: number
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
const albumId = getAlbumIdFromLink(tripId, linkId, userId);
if (!albumId) return { error: 'Album link not found', status: 404 };
const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return { error: 'Album link not found', status: 404 };
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await fetch(`${creds.immich_url}/api/albums/${albumId}`, {
const resp = await fetch(`${creds.immich_url}/api/albums/${response.data}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
@@ -334,12 +335,12 @@ export async function syncAlbumAssets(
asset_ids: assets.map((a: any) => a.id),
};
const addResult = addTripPhotos(tripId, userId, true, [selection]);
if ('error' in addResult) return { error: addResult.error, status: addResult.status };
const result = await addTripPhotos(tripId, userId, true, [selection]);
if ('error' in result) return { error: result.error.message, status: result.error.status };
updateSyncTimeForAlbumLink(linkId);
return { success: true, added: addResult.added, total: assets.length };
return { success: true, added: result.data.added, total: assets.length };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}

View File

@@ -1,14 +1,29 @@
import { db, canAccessTrip } from '../db/database';
import { notifyTripMembers } from './notifications';
type ServiceError = { error: string; status: number };
import { broadcast } from '../websocket';
/**
* Verify that requestingUserId can access a shared photo belonging to ownerUserId.
* The asset must be shared (shared=1) and the requesting user must be a member of
* the same trip that contains the photo.
*/
type ServiceError = { success: false; error: { message: string; status: number } };
type ServiceResult<T> = { success: true; data: T } | ServiceError;
function fail(error: string, status: number): ServiceError {
return { success: false, error: { message: error, status }};
}
function success<T>(data: T): ServiceResult<T> {
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;
@@ -27,15 +42,70 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
if (!sharedAsset) {
return false;
}
return !!canAccessTrip(String(tripId), requestingUserId);
return !!canAccessTrip(tripId, requestingUserId);
}
function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null {
if (!canAccessTrip(tripId, userId)) {
return { error: 'Trip not found', status: 404 };
export function listTripPhotos(tripId: string, userId: number): ServiceResult<any[]> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
return null;
try {
const photos = db.prepare(`
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar
FROM trip_photos tp
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
ORDER BY tp.added_at ASC
`).all(tripId, userId) as any[];
return success(photos);
} catch (error) {
return mapDbError(error, 'Failed to list trip photos');
}
}
export function listTripAlbumLinks(tripId: string, userId: number): ServiceResult<any[]> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
const links = db.prepare(`
SELECT tal.id,
tal.trip_id,
tal.user_id,
tal.provider,
tal.album_id,
tal.album_name,
tal.sync_enabled,
tal.last_synced_at,
tal.created_at,
u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
ORDER BY tal.created_at ASC
`).all(tripId);
return success(links);
} catch (error) {
return mapDbError(error, 'Failed to list trip album links');
}
}
//-----------------------------------------------
// managing photos in trip
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean {
const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, assetId, provider, shared ? 1 : 0);
return result.changes > 0;
}
export type Selection = {
@@ -43,234 +113,198 @@ export type Selection = {
asset_ids: string[];
};
//fallback for old clients that don't send selections as an array of provider/asset_id groups
export function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] {
const selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null;
const provider = String(providerRaw || '').toLowerCase();
if (selectionsFromBody && selectionsFromBody.length > 0) {
return selectionsFromBody
.map((selection: any) => ({
provider: String(selection?.provider || '').toLowerCase(),
asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [],
}))
.filter((selection: Selection) => selection.provider && selection.asset_ids.length > 0);
}
if (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0) {
return [{ provider, asset_ids: assetIdsRaw }];
}
return [];
}
export function listTripPhotos(tripId: string, userId: number): { photos: any[] } | ServiceError {
const denied = accessDeniedIfMissing(tripId, userId);
if (denied) return denied;
const photos = db.prepare(`
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar
FROM trip_photos tp
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
ORDER BY tp.added_at ASC
`).all(tripId, userId) as any[];
return { photos };
}
export function listTripAlbumLinks(tripId: string, userId: number): { links: any[] } | ServiceError {
const denied = accessDeniedIfMissing(tripId, userId);
if (denied) return denied;
const links = db.prepare(`
SELECT tal.id,
tal.trip_id,
tal.user_id,
tal.provider,
tal.album_id,
tal.album_name,
tal.sync_enabled,
tal.last_synced_at,
tal.created_at,
u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
ORDER BY tal.created_at ASC
`).all(tripId);
return { links };
}
export function createTripAlbumLink(
export async function addTripPhotos(
tripId: string,
userId: number,
providerRaw: unknown,
albumIdRaw: unknown,
albumNameRaw: unknown,
): { success: true } | ServiceError {
const denied = accessDeniedIfMissing(tripId, userId);
if (denied) return denied;
shared: boolean,
selections: Selection[],
sid?: string,
): Promise<ServiceResult<{ added: number; shared: boolean }>> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
if (selections.length === 0) {
return fail('No photos selected', 400);
}
try {
let added = 0;
for (const selection of selections) {
for (const raw of selection.asset_ids) {
const assetId = String(raw || '').trim();
if (!assetId) continue;
if (_addTripPhoto(tripId, userId, selection.provider, assetId, shared)) {
added++;
}
}
}
await _notifySharedTripPhotos(tripId, userId, added);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success({ added, shared });
} catch (error) {
return mapDbError(error, 'Failed to add trip photos');
}
}
export async function setTripPhotoSharing(
tripId: string,
userId: number,
provider: string,
assetId: string,
shared: boolean,
sid?: string,
): Promise<ServiceResult<true>> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
db.prepare(`
UPDATE trip_photos
SET shared = ?
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(shared ? 1 : 0, tripId, userId, assetId, provider);
await _notifySharedTripPhotos(tripId, userId, 1);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to update photo sharing');
}
}
export function removeTripPhoto(
tripId: string,
userId: number,
provider: string,
assetId: string,
sid?: string,
): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
try {
db.prepare(`
DELETE FROM trip_photos
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(tripId, userId, assetId, provider);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove trip photo');
}
}
// ----------------------------------------------
// managing album links in trip
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
const provider = String(providerRaw || '').toLowerCase();
const albumId = String(albumIdRaw || '').trim();
const albumName = String(albumNameRaw || '').trim();
if (!provider) {
return { error: 'provider is required', status: 400 };
return fail('provider is required', 400);
}
if (!albumId) {
return { error: 'album_id required', status: 400 };
return fail('album_id required', 400);
}
try {
db.prepare(
const result = db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName);
return { success: true };
} catch {
return { error: 'Album already linked', status: 400 };
}
}
export function removeAlbumLink(tripId: string, linkId: string, userId: number): { success: true } | ServiceError {
const denied = accessDeniedIfMissing(tripId, userId);
if (denied) return denied;
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
return { success: true };
}
function addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean {
const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, assetId, provider, shared ? 1 : 0);
return result.changes > 0;
}
export function addTripPhotos(
tripId: string,
userId: number,
shared: boolean,
selections: Selection[],
): { success: true; added: number; shared: boolean } | ServiceError {
const denied = accessDeniedIfMissing(tripId, userId);
if (denied) return denied;
if (selections.length === 0) {
return { error: 'No photos selected', status: 400 };
}
let added = 0;
for (const selection of selections) {
for (const raw of selection.asset_ids) {
const assetId = String(raw || '').trim();
if (!assetId) continue;
if (addTripPhoto(tripId, userId, selection.provider, assetId, shared)) {
added++;
}
if (result.changes === 0) {
return fail('Album already linked', 409);
}
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to link album');
}
return { success: true, added, shared };
}
export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): string {
const denied = accessDeniedIfMissing(tripId, userId);
if (denied) return null;
export function removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
const { album_id } = 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;
try {
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
return album_id;
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove album link');
}
}
//helpers for album link syncing
export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult<string> {
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
export function removeTripPhoto(
tripId: string,
userId: number,
providerRaw: unknown,
assetIdRaw: unknown,
): { success: true } | ServiceError {
const assetId = String(assetIdRaw || '');
const provider = String(providerRaw || '').toLowerCase();
if (!assetId) {
return { error: 'asset_id is required', status: 400 };
}
if (!provider) {
return { error: 'provider is required', status: 400 };
}
const denied = accessDeniedIfMissing(tripId, userId);
if (denied) return denied;
db.prepare(`
DELETE FROM trip_photos
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(tripId, userId, assetId, provider);
return { success: true };
}
export function setTripPhotoSharing(
tripId: string,
userId: number,
providerRaw: unknown,
assetIdRaw: unknown,
sharedRaw: unknown,
): { success: true } | ServiceError {
const assetId = String(assetIdRaw || '');
const provider = String(providerRaw || '').toLowerCase();
if (!assetId) {
return { error: 'asset_id is required', status: 400 };
}
if (!provider) {
return { error: 'provider is required', status: 400 };
}
const denied = accessDeniedIfMissing(tripId, userId);
if (denied) return denied;
db.prepare(`
UPDATE trip_photos
SET shared = ?
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(sharedRaw ? 1 : 0, tripId, userId, assetId, provider);
return { success: true };
}
export async function notifySharedTripPhotos(
async function _notifySharedTripPhotos(
tripId: string,
actorUserId: number,
actorName: string,
added: number,
): Promise<void> {
if (added <= 0) return;
): Promise<ServiceResult<void>> {
if (added <= 0) return fail('No photos shared, skipping notifications', 200);
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', {
trip: tripInfo?.title || 'Untitled',
actor: actorName,
count: String(added),
});
try {
const actorRow = db.prepare('SELECT username FROM users WHERE id = ?').get(actorUserId) as { username: string | null };
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', {
trip: tripInfo?.title || 'Untitled',
actor: actorRow?.username || 'Unknown',
count: String(added),
});
return success(undefined);
} catch {
return fail('Failed to send notifications', 500);
}
}

View File

@@ -6,6 +6,7 @@ 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';
const SYNOLOGY_API_TIMEOUT_MS = 30000;
const SYNOLOGY_PROVIDER = 'synologyphotos';
@@ -403,8 +404,8 @@ export async function listSynologyAlbums(userId: number): Promise<{ albums: Arra
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> {
const albumId = getAlbumIdFromLink(tripId, linkId, userId);
if (!albumId) {
const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) {
throw new SynologyServiceError(404, 'Album link not found');
}
@@ -417,7 +418,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
api: 'SYNO.Foto.Browse.Item',
method: 'list',
version: 1,
album_id: Number(albumId),
album_id: Number(response.data),
offset,
limit: pageSize,
additional: ['thumbnail'],
@@ -438,12 +439,12 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
};
const addResult = addTripPhotos(tripId, userId, true, [selection]);
if ('error' in addResult) throw new SynologyServiceError(addResult.status, addResult.error);
updateSyncTimeForAlbumLink(linkId);
return { added: addResult.added, total: allItems.length };
const result = await addTripPhotos(tripId, userId, true, [selection]);
if ('error' in result) throw new SynologyServiceError(result.error.status, result.error.message);
return { added: result.data.added, total: allItems.length };
}
export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> {