change in hadnling return values from unified service
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
Reference in New Issue
Block a user