generated from nhcarrigan/template
7579f1ec97
## Summary This PR implements several improvements to the library application: - Added start and finish date tracking for media items - Added "Retired" category for abandoned media - Implemented avatar-based user menu with dropdown navigation - Added automatic background token refresh to prevent session expiry - Created centralised logging system with frontend-to-API log forwarding - Added toast notifications for error handling ## Changes ### Media Tracking (#41) - Added `dateStarted` and `dateFinished` fields to Books, Games, Manga, Music, and Shows - Updated TypeScript types, Prisma schema, and API services - Added manual date input fields to frontend forms - Properly converts HTML date strings to Date objects before API submission ### Retired Category (#43) - Added `RETIRED` status to all media type enums - Updated Prisma schema, frontend dropdowns, and filter buttons - Added status label handling for retired items ### User Menu (#46) - Replaced username text with avatar image in header - Created dropdown menu with navigation items (Users, Audit, Suggestions) - Added logout button to menu - Implemented keyboard accessibility (tabindex, role, keyup handlers) ### Token Refresh (#44) - Implemented automatic token refresh every 13 minutes in background - Added proactive refresh to prevent token expiry during form filling - Prevents users from losing form data due to expired sessions ### Centralised Logging (#1) - Created `/log` endpoint on API to receive frontend logs - Replaced API console.log calls with @nhcarrigan/logger - Created ConsoleLoggerService to intercept all console methods on frontend - Added global error handlers (window.error, unhandledrejection) on frontend - Added process error handlers (uncaughtException, unhandledRejection, SIGTERM, SIGINT) on API - All frontend console activity now forwarded to centralised logging ### Error Handling - Created ToastService and ToastComponent for displaying errors - Integrated with GlobalErrorHandler and HTTP interceptor - Added accessibility features (keyboard navigation, ARIA attributes) - Set toast opacity to 40% for optimal readability ### Testing & Build - Fixed pre-existing test failure for GET / route (now returns version info) - Added ESM module mocking (jsdom, marked, dompurify, @nhcarrigan/logger) - Configured Jest with isolatedModules to handle TypeScript errors - Excluded test-setup.ts from production build - All tests passing (123 total) - Build passing with no errors ## Test Plan - [x] All tests pass (123 tests) - [x] Build passes without errors - [x] Lint passes (only pre-existing warnings) - [x] Date fields work correctly on all media types - [x] Retired status displays and filters properly - [x] Avatar menu opens/closes correctly with keyboard and mouse - [x] Token refresh prevents session expiry - [x] Toast notifications appear for errors - [x] Frontend logs forward to API successfully - [x] Root route returns version information Closes #41 Closes #43 Closes #44 Closes #46 Closes #1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #50 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
220 lines
6.3 KiB
TypeScript
220 lines
6.3 KiB
TypeScript
/**
|
|
* @copyright 2026 NHCarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
|
|
import { FastifyPluginAsync } from "fastify";
|
|
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
|
import { ArtService } from "../../services/art.service";
|
|
import { CommentService } from "../../services/comment.service";
|
|
import { AuditService } from "../../services/audit.service";
|
|
import { adminGuard } from "../../middleware/admin-guard";
|
|
import { bannedGuard } from "../../middleware/banned-guard";
|
|
|
|
const artRoutes: FastifyPluginAsync = async (app) => {
|
|
const artService = new ArtService();
|
|
const commentService = new CommentService();
|
|
|
|
/**
|
|
* Get all art (public route).
|
|
*/
|
|
app.get<{ Reply: Art[] }>("/", async () => {
|
|
return artService.getAllArt();
|
|
});
|
|
|
|
/**
|
|
* Get single art piece by ID (public route).
|
|
*/
|
|
app.get<{ Params: { id: string }; Reply: Art | null }>(
|
|
"/:id",
|
|
async (request) => {
|
|
const { id } = request.params;
|
|
return artService.getArtById(id);
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Create new art piece (admin only).
|
|
*/
|
|
app.post<{ Body: CreateArtDto; Reply: Art }>(
|
|
"/",
|
|
{
|
|
preValidation: [app.authenticate, adminGuard],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request) => {
|
|
const art = await artService.createArt(request.body);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.entryCreate,
|
|
category: AuditCategory.content,
|
|
resourceType: "art",
|
|
resourceId: art.id,
|
|
details: `Created art: ${art.title}`,
|
|
});
|
|
return art;
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Update art by ID (admin only).
|
|
*/
|
|
app.put<{
|
|
Params: { id: string };
|
|
Body: UpdateArtDto;
|
|
Reply: Art | null;
|
|
}>(
|
|
"/:id",
|
|
{
|
|
preValidation: [app.authenticate, adminGuard],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request) => {
|
|
const { id } = request.params;
|
|
const art = await artService.updateArt(id, request.body);
|
|
if (art) {
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.entryUpdate,
|
|
category: AuditCategory.content,
|
|
resourceType: "art",
|
|
resourceId: id,
|
|
details: `Updated art: ${art.title}`,
|
|
});
|
|
}
|
|
return art;
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Delete art by ID (admin only).
|
|
*/
|
|
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
|
"/:id",
|
|
{
|
|
preValidation: [app.authenticate, adminGuard],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request) => {
|
|
const { id } = request.params;
|
|
await artService.deleteArt(id);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.entryDelete,
|
|
category: AuditCategory.content,
|
|
resourceType: "art",
|
|
resourceId: id,
|
|
details: `Deleted art with ID: ${id}`,
|
|
});
|
|
return { success: true };
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Get comments for an art piece (public route).
|
|
*/
|
|
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
|
"/:id/comments",
|
|
async (request) => {
|
|
const { id } = request.params;
|
|
return commentService.getCommentsForArt(id);
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Add comment to an art piece (authenticated users).
|
|
*/
|
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
|
"/:id/comments",
|
|
{
|
|
preValidation: [app.authenticate, bannedGuard],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request) => {
|
|
const { id } = request.params;
|
|
const userId = request.user.id;
|
|
const comment = await commentService.createCommentForArt(id, userId, request.body);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.commentCreate,
|
|
category: AuditCategory.content,
|
|
resourceType: "art",
|
|
resourceId: id,
|
|
details: `Added comment to art`,
|
|
});
|
|
return comment;
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Update comment (owner or admin).
|
|
*/
|
|
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
|
|
"/:id/comments/:commentId",
|
|
{
|
|
preValidation: [app.authenticate],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request, reply) => {
|
|
const { id, commentId } = request.params;
|
|
const userId = request.user.id;
|
|
const isAdmin = request.user.isAdmin;
|
|
|
|
const verification = await commentService.verifyCommentOwnership(commentId, "art", id);
|
|
|
|
if (!verification.exists) {
|
|
return reply.code(404).send({ error: "Comment not found" });
|
|
}
|
|
|
|
if (verification.comment?.userId !== userId && !isAdmin) {
|
|
return reply.code(403).send({ error: "You can only edit your own comments" });
|
|
}
|
|
|
|
const comment = await commentService.updateComment(commentId, request.body.content);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.commentUpdate,
|
|
category: AuditCategory.content,
|
|
resourceType: "art",
|
|
resourceId: id,
|
|
details: `Updated comment ${commentId} on art`,
|
|
});
|
|
return comment;
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Delete comment (owner or admin).
|
|
*/
|
|
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
|
|
"/:id/comments/:commentId",
|
|
{
|
|
preValidation: [app.authenticate],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request, reply) => {
|
|
const { id, commentId } = request.params;
|
|
const userId = request.user.id;
|
|
const isAdmin = request.user.isAdmin;
|
|
|
|
const verification = await commentService.verifyCommentOwnership(commentId, "art", id);
|
|
|
|
if (!verification.exists) {
|
|
return reply.code(404).send({ error: "Comment not found" });
|
|
}
|
|
|
|
if (verification.comment?.userId !== userId && !isAdmin) {
|
|
return reply.code(403).send({ error: "You can only delete your own comments" });
|
|
}
|
|
|
|
await commentService.deleteComment(commentId);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.commentDelete,
|
|
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
|
|
resourceType: "art",
|
|
resourceId: id,
|
|
details: `Deleted comment ${commentId} from art`,
|
|
});
|
|
return { success: true };
|
|
}
|
|
);
|
|
};
|
|
|
|
export default artRoutes;
|