moving memories bl
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
199
server/src/services/memoriesService.ts
Normal file
199
server/src/services/memoriesService.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user