moving memories bl
This commit is contained in:
@@ -1,8 +1,16 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
|
import {
|
||||||
|
listTripPhotos,
|
||||||
|
listTripAlbumLinks,
|
||||||
|
removeAlbumLink,
|
||||||
|
addTripPhotos,
|
||||||
|
removeTripPhoto,
|
||||||
|
setTripPhotoSharing,
|
||||||
|
notifySharedTripPhotos,
|
||||||
|
} from '../services/memoriesService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -10,63 +18,24 @@ const router = express.Router();
|
|||||||
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);
|
||||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
||||||
return res.status(404).json({ error: 'Trip not found' });
|
res.json({ photos: result.photos });
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
router.get('/trips/:tripId/album-links', 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 = listTripAlbumLinks(tripId, authReq.user.id);
|
||||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
||||||
return res.status(404).json({ error: 'Trip not found' });
|
res.json({ links: result.links });
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
|
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, linkId } = req.params;
|
const { tripId, linkId } = req.params;
|
||||||
|
const result = removeAlbumLink(tripId, linkId, authReq.user.id);
|
||||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
||||||
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);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
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) => {
|
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 { shared = true } = req.body;
|
const result = addTripPhotos(
|
||||||
const selectionsRaw = Array.isArray(req.body?.selections) ? req.body.selections : null;
|
tripId,
|
||||||
const provider = String(req.body?.provider || '').toLowerCase();
|
authReq.user.id,
|
||||||
const assetIdsRaw = req.body?.asset_ids;
|
req.body?.shared,
|
||||||
|
req.body?.selections,
|
||||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
req.body?.provider,
|
||||||
return res.status(404).json({ error: 'Trip not found' });
|
req.body?.asset_ids,
|
||||||
}
|
|
||||||
|
|
||||||
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 (?, ?, ?, ?, ?)'
|
|
||||||
);
|
);
|
||||||
|
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
||||||
|
|
||||||
let added = 0;
|
res.json({ success: true, added: result.added });
|
||||||
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 });
|
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||||
|
|
||||||
if (shared && added > 0) {
|
if (result.shared && result.added > 0) {
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
void notifySharedTripPhotos(
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
tripId,
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', {
|
authReq.user.id,
|
||||||
trip: tripInfo?.title || 'Untitled',
|
authReq.user.username || authReq.user.email,
|
||||||
actor: authReq.user.username || authReq.user.email,
|
result.added,
|
||||||
count: String(added),
|
).catch(() => {});
|
||||||
}).catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
router.delete('/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 provider = String(req.body?.provider || '').toLowerCase();
|
const result = removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
|
||||||
const assetId = String(req.body?.asset_id || '');
|
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
const provider = String(req.body?.provider || '').toLowerCase();
|
const result = setTripPhotoSharing(
|
||||||
const assetId = String(req.body?.asset_id || '');
|
tripId,
|
||||||
const { shared } = req.body;
|
authReq.user.id,
|
||||||
|
req.body?.provider,
|
||||||
if (!assetId) {
|
req.body?.asset_id,
|
||||||
return res.status(400).json({ error: 'asset_id is required' });
|
req.body?.shared,
|
||||||
}
|
);
|
||||||
|
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
||||||
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);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
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