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 express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth'; import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest } from '../types'; import { AuthRequest } from '../types';
import { import {
listTripPhotos, listTripPhotos,
@@ -10,94 +9,85 @@ import {
addTripPhotos, addTripPhotos,
removeTripPhoto, removeTripPhoto,
setTripPhotoSharing, setTripPhotoSharing,
notifySharedTripPhotos,
normalizeSelections,
} from '../services/memoriesService'; } from '../services/memoriesService';
const router = express.Router(); const router = express.Router();
//------------------------------------------------
// routes for managing photos linked to trip
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId } = req.params; const { tripId } = req.params;
const result = listTripPhotos(tripId, authReq.user.id); const result = listTripPhotos(tripId, authReq.user.id);
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({ photos: result.photos }); res.json({ photos: result.data });
}); });
router.get('/trips/:tripId/album-links', 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 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) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId } = req.params; 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 shared = req.body?.shared === undefined ? true : !!req.body?.shared;
const result = addTripPhotos( const result = await addTripPhotos(
tripId, tripId,
authReq.user.id, authReq.user.id,
shared, 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 }); res.json({ success: true, added: result.data.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(() => {});
}
}); });
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 authReq = req as AuthRequest;
const { tripId } = req.params; const { tripId } = req.params;
const result = removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id); const result = await setTripPhotoSharing(
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(
tripId, tripId,
authReq.user.id, authReq.user.id,
req.body?.provider, req.body?.provider,
req.body?.asset_id, req.body?.asset_id,
req.body?.shared, 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 }); 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 authReq = req as AuthRequest;
const { tripId } = req.params; const { tripId } = req.params;
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name); 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 }); 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 { checkSsrf } from '../utils/ssrfGuard';
import { writeAudit } from './auditLog'; import { writeAudit } from './auditLog';
import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService';
import { error } from 'node:console';
// ── Credentials ──────────────────────────────────────────────────────────── // ── Credentials ────────────────────────────────────────────────────────────
@@ -314,14 +315,14 @@ export async function syncAlbumAssets(
linkId: string, linkId: string,
userId: number userId: number
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { ): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
const albumId = getAlbumIdFromLink(tripId, linkId, userId); const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!albumId) return { error: 'Album link not found', status: 404 }; if (!response.success) return { error: 'Album link not found', status: 404 };
const creds = getImmichCredentials(userId); const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 }; if (!creds) return { error: 'Immich not configured', status: 400 };
try { 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' }, headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000), signal: AbortSignal.timeout(15000),
}); });
@@ -334,12 +335,12 @@ export async function syncAlbumAssets(
asset_ids: assets.map((a: any) => a.id), asset_ids: assets.map((a: any) => a.id),
}; };
const addResult = addTripPhotos(tripId, userId, true, [selection]); const result = await addTripPhotos(tripId, userId, true, [selection]);
if ('error' in addResult) return { error: addResult.error, status: addResult.status }; if ('error' in result) return { error: result.error.message, status: result.error.status };
updateSyncTimeForAlbumLink(linkId); updateSyncTimeForAlbumLink(linkId);
return { success: true, added: addResult.added, total: assets.length }; return { success: true, added: result.data.added, total: assets.length };
} catch { } catch {
return { error: 'Could not reach Immich', status: 502 }; return { error: 'Could not reach Immich', status: 502 };
} }

View File

@@ -1,14 +1,29 @@
import { db, canAccessTrip } from '../db/database'; import { db, canAccessTrip } from '../db/database';
import { notifyTripMembers } from './notifications'; import { notifyTripMembers } from './notifications';
import { broadcast } from '../websocket';
type ServiceError = { error: string; status: number };
/** type ServiceError = { success: false; error: { message: string; status: number } };
* Verify that requestingUserId can access a shared photo belonging to ownerUserId. type ServiceResult<T> = { success: true; data: T } | ServiceError;
* The asset must be shared (shared=1) and the requesting user must be a member of
* the same trip that contains the photo. 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 { export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
if (requestingUserId === ownerUserId) { if (requestingUserId === ownerUserId) {
return true; return true;
@@ -27,15 +42,70 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
if (!sharedAsset) { if (!sharedAsset) {
return false; return false;
} }
return !!canAccessTrip(String(tripId), requestingUserId); return !!canAccessTrip(tripId, requestingUserId);
} }
export function listTripPhotos(tripId: string, userId: number): ServiceResult<any[]> {
function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null { const access = canAccessTrip(tripId, userId);
if (!canAccessTrip(tripId, userId)) { if (!access) {
return { error: 'Trip not found', status: 404 }; 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 = { export type Selection = {
@@ -43,234 +113,198 @@ export type Selection = {
asset_ids: string[]; asset_ids: string[];
}; };
export async function addTripPhotos(
//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(
tripId: string, tripId: string,
userId: number, userId: number,
providerRaw: unknown, shared: boolean,
albumIdRaw: unknown, selections: Selection[],
albumNameRaw: unknown, sid?: string,
): { success: true } | ServiceError { ): Promise<ServiceResult<{ added: number; shared: boolean }>> {
const denied = accessDeniedIfMissing(tripId, userId); const access = canAccessTrip(tripId, userId);
if (denied) return denied; 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 provider = String(providerRaw || '').toLowerCase();
const albumId = String(albumIdRaw || '').trim(); const albumId = String(albumIdRaw || '').trim();
const albumName = String(albumNameRaw || '').trim(); const albumName = String(albumNameRaw || '').trim();
if (!provider) { if (!provider) {
return { error: 'provider is required', status: 400 }; return fail('provider is required', 400);
} }
if (!albumId) { if (!albumId) {
return { error: 'album_id required', status: 400 }; return fail('album_id required', 400);
} }
try { try {
db.prepare( const result = db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)' 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName); ).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 { if (result.changes === 0) {
const denied = accessDeniedIfMissing(tripId, userId); return fail('Album already linked', 409);
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++;
}
} }
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 { export function removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult<true> {
const denied = accessDeniedIfMissing(tripId, userId); const access = canAccessTrip(tripId, userId);
if (denied) return null; 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 { export function updateSyncTimeForAlbumLink(linkId: string): void {
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
} }
//-----------------------------------------------
// notifications helper
async function _notifySharedTripPhotos(
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(
tripId: string, tripId: string,
actorUserId: number, actorUserId: number,
actorName: string,
added: number, added: number,
): Promise<void> { ): Promise<ServiceResult<void>> {
if (added <= 0) return; 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; try {
await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', { const actorRow = db.prepare('SELECT username FROM users WHERE id = ?').get(actorUserId) as { username: string | null };
trip: tripInfo?.title || 'Untitled',
actor: actorName, const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
count: String(added), 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 { checkSsrf } from '../utils/ssrfGuard';
import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService';
import { error } from 'node:console'; import { error } from 'node:console';
import { th } from 'zod/locales';
const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_API_TIMEOUT_MS = 30000;
const SYNOLOGY_PROVIDER = 'synologyphotos'; 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 }> { export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> {
const albumId = getAlbumIdFromLink(tripId, linkId, userId); const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!albumId) { if (!response.success) {
throw new SynologyServiceError(404, 'Album link not found'); 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', api: 'SYNO.Foto.Browse.Item',
method: 'list', method: 'list',
version: 1, version: 1,
album_id: Number(albumId), album_id: Number(response.data),
offset, offset,
limit: pageSize, limit: pageSize,
additional: ['thumbnail'], 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), 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); 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 }> { export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> {