diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts index 6b510f3..6bd6e6f 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories.ts @@ -1,8 +1,16 @@ import express, { Request, Response } from 'express'; -import { db, canAccessTrip } from '../db/database'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; +import { + listTripPhotos, + listTripAlbumLinks, + removeAlbumLink, + addTripPhotos, + removeTripPhoto, + setTripPhotoSharing, + notifySharedTripPhotos, +} from '../services/memoriesService'; const router = express.Router(); @@ -10,63 +18,24 @@ const router = express.Router(); router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - 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, authReq.user.id) as any[]; - - res.json({ photos }); + 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 }); }); router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - 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); - - res.json({ links }); + 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; - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .run(linkId, tripId, authReq.user.id); - + 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); }); @@ -74,85 +43,34 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const { shared = true } = req.body; - const selectionsRaw = Array.isArray(req.body?.selections) ? req.body.selections : null; - const provider = String(req.body?.provider || '').toLowerCase(); - const assetIdsRaw = req.body?.asset_ids; - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - const selections = selectionsRaw && selectionsRaw.length > 0 - ? selectionsRaw - .map((selection: any) => ({ - provider: String(selection?.provider || '').toLowerCase(), - asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [], - })) - .filter((selection: { provider: string; asset_ids: unknown[] }) => selection.provider && selection.asset_ids.length > 0) - : (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0 - ? [{ provider, asset_ids: assetIdsRaw }] - : []); - - if (selections.length === 0) { - return res.status(400).json({ error: 'selections required' }); - } - - const insert = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + const result = addTripPhotos( + tripId, + authReq.user.id, + req.body?.shared, + req.body?.selections, + req.body?.provider, + req.body?.asset_ids, ); + if ('error' in result) return res.status(result.status).json({ error: result.error }); - let added = 0; - for (const selection of selections) { - for (const raw of selection.asset_ids) { - const assetId = String(raw || '').trim(); - if (!assetId) continue; - const result = insert.run(tripId, authReq.user.id, assetId, selection.provider, shared ? 1 : 0); - if (result.changes > 0) added++; - } - } - - res.json({ success: true, added }); + res.json({ success: true, added: result.added }); broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - if (shared && added > 0) { - import('../services/notifications').then(({ notifyTripMembers }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { - trip: tripInfo?.title || 'Untitled', - actor: authReq.user.username || authReq.user.email, - count: String(added), - }).catch(() => {}); - }); + 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) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const provider = String(req.body?.provider || '').toLowerCase(); - const assetId = String(req.body?.asset_id || ''); - - if (!assetId) { - return res.status(400).json({ error: 'asset_id is required' }); - } - - if (!provider) { - return res.status(400).json({ error: 'provider is required' }); - } - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - db.prepare(` - DELETE FROM trip_photos - WHERE trip_id = ? - AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(tripId, authReq.user.id, assetId, provider); - + 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); }); @@ -160,31 +78,14 @@ router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Respons router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const provider = String(req.body?.provider || '').toLowerCase(); - const assetId = String(req.body?.asset_id || ''); - const { shared } = req.body; - - if (!assetId) { - return res.status(400).json({ error: 'asset_id is required' }); - } - - if (!provider) { - return res.status(400).json({ error: 'provider is required' }); - } - - if (!canAccessTrip(tripId, authReq.user.id)) { - return res.status(404).json({ error: 'Trip not found' }); - } - - db.prepare(` - UPDATE trip_photos - SET shared = ? - WHERE trip_id = ? - AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(shared ? 1 : 0, tripId, authReq.user.id, assetId, provider); - + const result = 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 }); res.json({ success: true }); broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); }); diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts new file mode 100644 index 0000000..eb8acc3 --- /dev/null +++ b/server/src/services/memoriesService.ts @@ -0,0 +1,199 @@ +import { db, canAccessTrip } from '../db/database'; +import { notifyTripMembers } from './notifications'; + +type ServiceError = { error: string; status: number }; + +function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null { + if (!canAccessTrip(tripId, userId)) { + return { error: 'Trip not found', status: 404 }; + } + return null; +} + +type Selection = { + provider: string; + asset_ids: unknown[]; +}; + +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 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 }; +} + +export function addTripPhotos( + tripId: string, + userId: number, + sharedRaw: unknown, + selectionsRaw: unknown, + providerRaw: unknown, + assetIdsRaw: unknown, +): { success: true; added: number; shared: boolean } | ServiceError { + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return denied; + + const shared = sharedRaw === undefined ? true : !!sharedRaw; + const selections = normalizeSelections(selectionsRaw, providerRaw, assetIdsRaw); + if (selections.length === 0) { + return { error: 'selections required', status: 400 }; + } + + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ); + + let added = 0; + for (const selection of selections) { + for (const raw of selection.asset_ids) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + const result = insert.run(tripId, userId, assetId, selection.provider, shared ? 1 : 0); + if (result.changes > 0) added++; + } + } + + return { success: true, added, shared }; +} + +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, + actorUserId: number, + actorName: string, + added: number, +): Promise { + if (added <= 0) return; + + 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), + }); +}