import { FastifyInstance } from 'fastify'; import { prisma } from '../../../config/database'; import { optionalAuth } from '../middleware/auth'; import { createHash } from 'crypto'; import { logger } from '../../../utils/logger'; /** * Photo engagement routes — upvotes, comments, reactions, views (prefix: /api) */ interface UpvoteParams { id: string; } interface CommentBody { content: string; sessionId: string; } interface ReactionBody { sessionId: string; reactionType: string; } interface ViewBody { photoId: number; sessionId?: string; } const VALID_REACTIONS = ['like', 'love', 'laugh', 'wow', 'sad', 'angry']; export async function photoEngagementRoutes(fastify: FastifyInstance) { // POST /api/photos/:id/upvote - Toggle upvote on fastify.post<{ Params: UpvoteParams; Body: { sessionId: string } }>( '/photos/:id/upvote', { preHandler: optionalAuth }, async (request, reply) => { const photoId = parseInt(request.params.id as string); const { sessionId } = request.body; if (!sessionId) { return reply.code(400).send({ message: 'sessionId is required' }); } // Ensure session exists await prisma.session.upsert({ where: { id: sessionId }, create: { id: sessionId, userId: request.user?.id || null }, update: { lastSeenAt: new Date() }, }); // Check existing const existing = await prisma.photoUpvote.findFirst({ where: { photoId, sessionId }, }); if (existing) { return reply.code(409).send({ message: 'Already upvoted' }); } await prisma.photoUpvote.create({ data: { photoId, sessionId }, }); // Increment counter await prisma.photo.update({ where: { id: photoId }, data: { upvoteCount: { increment: 1 } }, }); return { message: 'Upvoted', upvoted: true }; } ); // DELETE /api/photos/:id/upvote - Remove upvote fastify.delete<{ Params: UpvoteParams; Body: { sessionId: string } }>( '/photos/:id/upvote', { preHandler: optionalAuth }, async (request, reply) => { const photoId = parseInt(request.params.id as string); const sessionId = (request.body as any)?.sessionId || (request.query as any)?.sessionId; if (!sessionId) { return reply.code(400).send({ message: 'sessionId is required' }); } const existing = await prisma.photoUpvote.findFirst({ where: { photoId, sessionId }, }); if (!existing) { return reply.code(404).send({ message: 'No upvote found' }); } await prisma.photoUpvote.delete({ where: { id: existing.id } }); await prisma.photo.update({ where: { id: photoId }, data: { upvoteCount: { decrement: 1 } }, }); return { message: 'Upvote removed', upvoted: false }; } ); // GET /api/photos/:id/comments - Get comments fastify.get<{ Params: { id: string }; Querystring: { limit?: string; offset?: string } }>( '/photos/:id/comments', { preHandler: optionalAuth }, async (request) => { const photoId = parseInt(request.params.id as string); const limit = Math.min(parseInt(request.query.limit || '50'), 200); const offset = parseInt(request.query.offset || '0'); const [comments, total] = await Promise.all([ prisma.photoComment.findMany({ where: { photoId, isHidden: false, safetyStatus: 'approved' }, orderBy: { createdAt: 'desc' }, take: limit, skip: offset, select: { id: true, content: true, createdAt: true, user: { select: { id: true, name: true }, }, }, }), prisma.photoComment.count({ where: { photoId, isHidden: false, safetyStatus: 'approved' }, }), ]); return { comments, total, limit, offset }; } ); // POST /api/photos/:id/comments - Add comment fastify.post<{ Params: { id: string }; Body: CommentBody }>( '/photos/:id/comments', { preHandler: optionalAuth }, async (request, reply) => { const photoId = parseInt(request.params.id as string); const { content, sessionId } = request.body; if (!content?.trim()) { return reply.code(400).send({ message: 'Content is required' }); } if (!sessionId) { return reply.code(400).send({ message: 'sessionId is required' }); } // Ensure session exists await prisma.session.upsert({ where: { id: sessionId }, create: { id: sessionId, userId: request.user?.id || null }, update: { lastSeenAt: new Date() }, }); const comment = await prisma.photoComment.create({ data: { photoId, sessionId, userId: request.user?.id || null, content: content.trim().slice(0, 2000), // Max 2000 chars }, }); // Increment counter await prisma.photo.update({ where: { id: photoId }, data: { commentCount: { increment: 1 } }, }); return reply.code(201).send({ comment }); } ); // POST /api/photos/:id/reactions - Add reaction fastify.post<{ Params: { id: string }; Body: ReactionBody }>( '/photos/:id/reactions', { preHandler: optionalAuth }, async (request, reply) => { const photoId = parseInt(request.params.id as string); const { sessionId, reactionType } = request.body; if (!sessionId) { return reply.code(400).send({ message: 'sessionId is required' }); } if (!VALID_REACTIONS.includes(reactionType)) { return reply.code(400).send({ message: `Invalid reaction. Must be: ${VALID_REACTIONS.join(', ')}` }); } // Ensure session exists await prisma.session.upsert({ where: { id: sessionId }, create: { id: sessionId, userId: request.user?.id || null }, update: { lastSeenAt: new Date() }, }); // Upsert reaction (one per session per type) await prisma.photoReaction.upsert({ where: { photoId_sessionId_reactionType: { photoId, sessionId, reactionType, }, }, create: { photoId, sessionId, reactionType }, update: {}, }); return { message: 'Reaction added' }; } ); // POST /api/track/photo-view - Record photo view fastify.post<{ Body: ViewBody }>( '/track/photo-view', { preHandler: optionalAuth }, async (request, reply) => { const { photoId, sessionId } = request.body; if (!photoId) { return reply.code(400).send({ message: 'photoId is required' }); } // Hash IP for privacy const ipRaw = request.ip || request.headers['x-forwarded-for'] || ''; const ipStr = Array.isArray(ipRaw) ? ipRaw[0] : ipRaw; const ipHash = createHash('sha256').update(ipStr).digest('hex').slice(0, 16); await prisma.photoView.create({ data: { photoId, sessionId: sessionId || null, userId: request.user?.id || null, ipAddressHash: ipHash, }, }); // Increment counter await prisma.photo.update({ where: { id: photoId }, data: { viewCount: { increment: 1 } }, }); return { message: 'View recorded' }; } ); }