Files
library/api/src/app/routes/art/index.ts
T
naomi 7579f1ec97
Node.js CI / CI (push) Successful in 1m18s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m17s
feat: multiple improvements to library functionality (#50)
## 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>
2026-02-19 16:52:43 -08:00

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;