moving memories bl

This commit is contained in:
Marek Maslowski
2026-04-03 17:02:53 +02:00
parent 21f87d9b91
commit fa25ff29bb
2 changed files with 242 additions and 142 deletions

View File

@@ -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);
});

View File

@@ -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<void> {
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),
});
}