254 lines
7.2 KiB
TypeScript
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' };
|
|
}
|
|
);
|
|
}
|