Files
library/api/src/app/services/audit.service.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

143 lines
3.7 KiB
TypeScript

import type { FastifyRequest } from "fastify";
import { prisma } from "../lib/prisma";
import type { AuditAction, AuditCategory, AuditLogFilters } from "@library/shared-types";
interface AuditLogData {
action: AuditAction;
category: AuditCategory;
userId?: string;
targetUserId?: string;
resourceType?: string;
resourceId?: string;
details?: string;
success?: boolean;
}
export const AuditService = {
async log(data: AuditLogData, request?: FastifyRequest) {
const userAgent = request?.headers["user-agent"] ?? undefined;
return prisma.auditLog.create({
data: {
action: data.action,
category: data.category,
userId: data.userId,
targetUserId: data.targetUserId,
resourceType: data.resourceType,
resourceId: data.resourceId,
details: data.details,
userAgent,
success: data.success ?? true,
},
});
},
async logFromRequest(
request: FastifyRequest,
data: Omit<AuditLogData, "userId">
) {
const userId = ((request as any).user as { id?: string } | undefined)?.id;
return this.log(
{
...data,
userId,
},
request
);
},
async getLogs(filters: AuditLogFilters = {}) {
const { action, category, userId, success, startDate, endDate, page = 1, limit = 50 } = filters;
const where: Record<string, unknown> = {};
if (action) {
where.action = action;
}
if (category) {
where.category = category;
}
if (userId) {
where.userId = userId;
}
if (success !== undefined) {
where.success = success;
}
if (startDate || endDate) {
where.createdAt = {};
if (startDate) {
(where.createdAt as Record<string, Date>).gte = startDate;
}
if (endDate) {
(where.createdAt as Record<string, Date>).lte = endDate;
}
}
const [rawLogs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
}),
prisma.auditLog.count({ where }),
]);
// Collect all unique user IDs to fetch
const userIds = new Set<string>();
for (const log of rawLogs) {
if (log.userId) {
userIds.add(log.userId);
}
if (log.targetUserId) {
userIds.add(log.targetUserId);
}
}
// Fetch all users in one query
const users = userIds.size > 0
? await prisma.user.findMany({
where: { id: { in: Array.from(userIds) } },
select: { id: true, username: true, avatar: true },
})
: [];
// Create a lookup map
const userMap = new Map(users.map(u => [u.id, { id: u.id, username: u.username, avatar: u.avatar ?? undefined }]));
// Map logs with user info
const logs = rawLogs.map(log => ({
id: log.id,
action: log.action,
category: log.category,
userId: log.userId ?? undefined,
user: log.userId ? userMap.get(log.userId) : undefined,
targetUserId: log.targetUserId ?? undefined,
targetUser: log.targetUserId ? userMap.get(log.targetUserId) : undefined,
resourceType: log.resourceType ?? undefined,
resourceId: log.resourceId ?? undefined,
details: log.details ?? undefined,
userAgent: log.userAgent ?? undefined,
success: log.success,
createdAt: log.createdAt,
}));
return {
logs,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
},
async getLogsByUser(userId: string, page = 1, limit = 50) {
return this.getLogs({ userId, page, limit });
},
async getSecurityLogs(page = 1, limit = 50) {
return this.getLogs({ category: "SECURITY" as AuditCategory, page, limit });
},
};