changemaker.lite/api/src/modules/media/routes/photo-engagement.routes.ts

254 lines
7.2 KiB
TypeScript

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