generated from nhcarrigan/template
feat: add ability to like books
This commit is contained in:
@@ -178,6 +178,7 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
suggestions Suggestion[]
|
||||
likes Like[]
|
||||
}
|
||||
|
||||
model Comment {
|
||||
@@ -226,6 +227,8 @@ enum AuditAction {
|
||||
ENTRY_CREATE
|
||||
ENTRY_UPDATE
|
||||
ENTRY_DELETE
|
||||
LIKE
|
||||
UNLIKE
|
||||
USER_BAN
|
||||
USER_UNBAN
|
||||
RATE_LIMIT_EXCEEDED
|
||||
@@ -275,3 +278,14 @@ enum SuggestionStatus {
|
||||
ACCEPTED
|
||||
DECLINED
|
||||
}
|
||||
|
||||
model Like {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
entityType String // 'book', 'game', 'show', 'manga', 'music', 'art'
|
||||
entityId String @db.ObjectId
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, entityType, entityId])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { CreateLikeDto, LikeResponse, LikedItemDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { LikeService } from "../../services/like.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const likesRoutes: FastifyPluginAsync = async (app) => {
|
||||
const likeService = new LikeService();
|
||||
|
||||
/**
|
||||
* Toggle like on an item (authenticated users).
|
||||
*/
|
||||
app.post<{ Body: CreateLikeDto; Reply: LikeResponse }>(
|
||||
"/toggle",
|
||||
{
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const userId = request.user.id;
|
||||
const { entityType, entityId } = request.body;
|
||||
const result = await likeService.toggleLike(userId, entityType, entityId, request);
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get like count for an item (public route).
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { entityType: string; entityId: string };
|
||||
}>(
|
||||
"/count",
|
||||
async (request, reply) => {
|
||||
const { entityType, entityId } = request.query;
|
||||
|
||||
// Validate entityType
|
||||
const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art'];
|
||||
if (!validTypes.includes(entityType)) {
|
||||
reply.code(400);
|
||||
return { error: "Invalid entity type" };
|
||||
}
|
||||
|
||||
if (!entityId) {
|
||||
reply.code(400);
|
||||
return { error: "Entity ID is required" };
|
||||
}
|
||||
|
||||
const count = await likeService.getLikeCount(entityType as any, entityId);
|
||||
return { count };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if current user has liked an item (authenticated users).
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { entityType: string; entityId: string };
|
||||
}>(
|
||||
"/status",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
const { entityType, entityId } = request.query;
|
||||
|
||||
// Validate entityType
|
||||
const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art'];
|
||||
if (!validTypes.includes(entityType)) {
|
||||
reply.code(400);
|
||||
return { error: "Invalid entity type" };
|
||||
}
|
||||
|
||||
if (!entityId) {
|
||||
reply.code(400);
|
||||
return { error: "Entity ID is required" };
|
||||
}
|
||||
|
||||
const liked = await likeService.getUserLikeStatus(userId, entityType as any, entityId);
|
||||
return { liked };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all items liked by the current user (authenticated users).
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: { entityType?: string };
|
||||
}>(
|
||||
"/user",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
const { entityType } = request.query;
|
||||
|
||||
// Validate entityType if provided
|
||||
if (entityType) {
|
||||
const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art'];
|
||||
if (!validTypes.includes(entityType)) {
|
||||
reply.code(400);
|
||||
return { error: "Invalid entity type" };
|
||||
}
|
||||
}
|
||||
|
||||
const likedItems = await likeService.getUserLikedItems(userId, entityType as any);
|
||||
return likedItems;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get bulk like statuses for multiple items (authenticated users).
|
||||
* Useful for efficiently loading like status for lists.
|
||||
*/
|
||||
app.post<{
|
||||
Body: { items: Array<{ entityType: string; entityId: string }> };
|
||||
}>(
|
||||
"/bulk-status",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
const { items } = request.body;
|
||||
|
||||
if (!items || !Array.isArray(items)) {
|
||||
reply.code(400);
|
||||
return { error: "Items array is required" };
|
||||
}
|
||||
|
||||
const validTypes = ['book', 'game', 'show', 'manga', 'music', 'art'];
|
||||
const results = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
if (!validTypes.includes(item.entityType)) {
|
||||
return {
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
liked: false,
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [liked, count] = await Promise.all([
|
||||
likeService.getUserLikeStatus(userId, item.entityType as any, item.entityId),
|
||||
likeService.getLikeCount(item.entityType as any, item.entityId),
|
||||
]);
|
||||
|
||||
return {
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
liked,
|
||||
count,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default likesRoutes;
|
||||
@@ -11,4 +11,5 @@ export { MusicService } from "./music.service";
|
||||
export { ShowService } from "./show.service";
|
||||
export { MangaService } from "./manga.service";
|
||||
export { AuditService } from "./audit.service";
|
||||
export { SuggestionService } from "./suggestion.service";
|
||||
export { SuggestionService } from "./suggestion.service";
|
||||
export { LikeService } from "./like.service";
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AuditService } from './audit.service';
|
||||
import type { Like, LikeCountDto, LikedItemDto, LikeResponse } from '@library/shared-types';
|
||||
import { AuditAction, AuditCategory } from '@library/shared-types';
|
||||
|
||||
export class LikeService {
|
||||
async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise<LikeResponse> {
|
||||
// Check if like exists
|
||||
const existingLike = await prisma.like.findUnique({
|
||||
where: {
|
||||
userId_entityType_entityId: {
|
||||
userId,
|
||||
entityType,
|
||||
entityId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingLike) {
|
||||
// Unlike
|
||||
await prisma.like.delete({
|
||||
where: {
|
||||
id: existingLike.id
|
||||
}
|
||||
});
|
||||
|
||||
await AuditService.logFromRequest(req, {
|
||||
action: AuditAction.UNLIKE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: entityType,
|
||||
resourceId: entityId,
|
||||
details: `Unliked ${entityType}`
|
||||
});
|
||||
|
||||
const count = await this.getLikeCount(entityType, entityId);
|
||||
return { liked: false, count };
|
||||
} else {
|
||||
// Like
|
||||
await prisma.like.create({
|
||||
data: {
|
||||
userId,
|
||||
entityType,
|
||||
entityId
|
||||
}
|
||||
});
|
||||
|
||||
await AuditService.logFromRequest(req, {
|
||||
action: AuditAction.LIKE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: entityType,
|
||||
resourceId: entityId,
|
||||
details: `Liked ${entityType}`
|
||||
});
|
||||
|
||||
const count = await this.getLikeCount(entityType, entityId);
|
||||
return { liked: true, count };
|
||||
}
|
||||
}
|
||||
|
||||
async getLikeCount(entityType: Like['entityType'], entityId: string): Promise<number> {
|
||||
return await prisma.like.count({
|
||||
where: {
|
||||
entityType,
|
||||
entityId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getUserLikeStatus(userId: string, entityType: Like['entityType'], entityId: string): Promise<boolean> {
|
||||
const like = await prisma.like.findUnique({
|
||||
where: {
|
||||
userId_entityType_entityId: {
|
||||
userId,
|
||||
entityType,
|
||||
entityId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return !!like;
|
||||
}
|
||||
|
||||
async getLikeCounts(entityType: Like['entityType'], entityIds: string[]): Promise<LikeCountDto[]> {
|
||||
const likes = await prisma.like.groupBy({
|
||||
by: ['entityId'],
|
||||
where: {
|
||||
entityType,
|
||||
entityId: { in: entityIds }
|
||||
},
|
||||
_count: true
|
||||
});
|
||||
|
||||
return likes.map(like => ({
|
||||
entityId: like.entityId,
|
||||
entityType,
|
||||
count: like._count
|
||||
}));
|
||||
}
|
||||
|
||||
async getUserLikeStatuses(userId: string, entityType: Like['entityType'], entityIds: string[]): Promise<Record<string, boolean>> {
|
||||
const likes = await prisma.like.findMany({
|
||||
where: {
|
||||
userId,
|
||||
entityType,
|
||||
entityId: { in: entityIds }
|
||||
},
|
||||
select: {
|
||||
entityId: true
|
||||
}
|
||||
});
|
||||
|
||||
const likeMap: Record<string, boolean> = {};
|
||||
entityIds.forEach(id => {
|
||||
likeMap[id] = false;
|
||||
});
|
||||
likes.forEach(like => {
|
||||
likeMap[like.entityId] = true;
|
||||
});
|
||||
|
||||
return likeMap;
|
||||
}
|
||||
|
||||
async getUserLikedItems(userId: string, entityType?: Like['entityType']): Promise<LikedItemDto[]> {
|
||||
const likes = await prisma.like.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(entityType ? { entityType } : {})
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch the actual items for each like
|
||||
const likedItems: LikedItemDto[] = [];
|
||||
|
||||
for (const like of likes) {
|
||||
let item: any = null;
|
||||
|
||||
switch (like.entityType) {
|
||||
case 'book':
|
||||
item = await prisma.book.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'game':
|
||||
item = await prisma.game.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'show':
|
||||
item = await prisma.show.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'manga':
|
||||
item = await prisma.manga.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'music':
|
||||
item = await prisma.music.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
case 'art':
|
||||
item = await prisma.art.findUnique({ where: { id: like.entityId } });
|
||||
break;
|
||||
}
|
||||
|
||||
if (item) {
|
||||
likedItems.push({
|
||||
like: {
|
||||
...like,
|
||||
entityType: like.entityType as Like['entityType']
|
||||
},
|
||||
item
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return likedItems;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user