generated from nhcarrigan/template
feat: audit logs show user info
This commit is contained in:
@@ -83,17 +83,14 @@ export default async function (app: FastifyInstance): Promise<void> {
|
|||||||
request.body
|
request.body
|
||||||
);
|
);
|
||||||
|
|
||||||
await AuditService.log(
|
await AuditService.logFromRequest(request, {
|
||||||
{
|
action: AuditAction.ENTRY_CREATE,
|
||||||
action: AuditAction.ENTRY_CREATE,
|
category: AuditCategory.CONTENT,
|
||||||
category: AuditCategory.CONTENT,
|
resourceType: "Suggestion",
|
||||||
resourceType: "Suggestion",
|
resourceId: suggestion.id,
|
||||||
resourceId: suggestion.id,
|
details: `Created ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
||||||
details: `Created ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
success: true,
|
||||||
success: true,
|
});
|
||||||
},
|
|
||||||
request
|
|
||||||
);
|
|
||||||
|
|
||||||
reply.send(suggestion);
|
reply.send(suggestion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -116,17 +113,14 @@ export default async function (app: FastifyInstance): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const suggestion = await SuggestionService.acceptSuggestion(id);
|
const suggestion = await SuggestionService.acceptSuggestion(id);
|
||||||
|
|
||||||
await AuditService.log(
|
await AuditService.logFromRequest(request, {
|
||||||
{
|
action: AuditAction.ENTRY_UPDATE,
|
||||||
action: AuditAction.ENTRY_UPDATE,
|
category: AuditCategory.ADMIN,
|
||||||
category: AuditCategory.ADMIN,
|
resourceType: "Suggestion",
|
||||||
resourceType: "Suggestion",
|
resourceId: suggestion.id,
|
||||||
resourceId: suggestion.id,
|
details: `Accepted ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
||||||
details: `Accepted ${suggestion.entityType} suggestion: ${suggestion.title}`,
|
success: true,
|
||||||
success: true,
|
});
|
||||||
},
|
|
||||||
request
|
|
||||||
);
|
|
||||||
|
|
||||||
reply.send(suggestion);
|
reply.send(suggestion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -150,17 +144,14 @@ export default async function (app: FastifyInstance): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const suggestion = await SuggestionService.acceptSuggestionWithEdits(id, editedData);
|
const suggestion = await SuggestionService.acceptSuggestionWithEdits(id, editedData);
|
||||||
|
|
||||||
await AuditService.log(
|
await AuditService.logFromRequest(request, {
|
||||||
{
|
action: AuditAction.ENTRY_UPDATE,
|
||||||
action: AuditAction.ENTRY_UPDATE,
|
category: AuditCategory.ADMIN,
|
||||||
category: AuditCategory.ADMIN,
|
resourceType: "Suggestion",
|
||||||
resourceType: "Suggestion",
|
resourceId: suggestion.id,
|
||||||
resourceId: suggestion.id,
|
details: `Accepted ${suggestion.entityType} suggestion with edits: ${suggestion.title}`,
|
||||||
details: `Accepted ${suggestion.entityType} suggestion with edits: ${suggestion.title}`,
|
success: true,
|
||||||
success: true,
|
});
|
||||||
},
|
|
||||||
request
|
|
||||||
);
|
|
||||||
|
|
||||||
reply.send(suggestion);
|
reply.send(suggestion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -184,17 +175,14 @@ export default async function (app: FastifyInstance): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const suggestion = await SuggestionService.declineSuggestion(id, reason);
|
const suggestion = await SuggestionService.declineSuggestion(id, reason);
|
||||||
|
|
||||||
await AuditService.log(
|
await AuditService.logFromRequest(request, {
|
||||||
{
|
action: AuditAction.ENTRY_UPDATE,
|
||||||
action: AuditAction.ENTRY_UPDATE,
|
category: AuditCategory.ADMIN,
|
||||||
category: AuditCategory.ADMIN,
|
resourceType: "Suggestion",
|
||||||
resourceType: "Suggestion",
|
resourceId: suggestion.id,
|
||||||
resourceId: suggestion.id,
|
details: `Declined ${suggestion.entityType} suggestion: ${suggestion.title}${reason ? ` (Reason: ${reason})` : ""}`,
|
||||||
details: `Declined ${suggestion.entityType} suggestion: ${suggestion.title}${reason ? ` (Reason: ${reason})` : ""}`,
|
success: true,
|
||||||
success: true,
|
});
|
||||||
},
|
|
||||||
request
|
|
||||||
);
|
|
||||||
|
|
||||||
reply.send(suggestion);
|
reply.send(suggestion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const AuditService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [logs, total] = await Promise.all([
|
const [rawLogs, total] = await Promise.all([
|
||||||
prisma.auditLog.findMany({
|
prisma.auditLog.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
@@ -84,6 +84,45 @@ export const AuditService = {
|
|||||||
prisma.auditLog.count({ where }),
|
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 {
|
return {
|
||||||
logs,
|
logs,
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -85,8 +85,10 @@ import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
|
<th>User</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
|
<th>Target User</th>
|
||||||
<th>Details</th>
|
<th>Details</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -95,6 +97,23 @@ import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types
|
|||||||
@for (log of logs(); track log.id) {
|
@for (log of logs(); track log.id) {
|
||||||
<tr [class.failed]="!log.success">
|
<tr [class.failed]="!log.success">
|
||||||
<td class="time">{{ formatDate(log.createdAt) }}</td>
|
<td class="time">{{ formatDate(log.createdAt) }}</td>
|
||||||
|
<td class="user-cell">
|
||||||
|
@if (log.user) {
|
||||||
|
<div class="user-info">
|
||||||
|
@if (log.user.avatar) {
|
||||||
|
<img [src]="log.user.avatar" [alt]="log.user.username" class="user-avatar" />
|
||||||
|
}
|
||||||
|
<div class="user-details">
|
||||||
|
<span class="username">{{ log.user.username }}</span>
|
||||||
|
<span class="user-id">{{ log.userId }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (log.userId) {
|
||||||
|
<span class="user-id-only">{{ log.userId }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="no-user">-</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
class="category-badge"
|
class="category-badge"
|
||||||
@@ -104,6 +123,23 @@ import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ auditService.getActionLabel(log.action) }}</td>
|
<td>{{ auditService.getActionLabel(log.action) }}</td>
|
||||||
|
<td class="user-cell">
|
||||||
|
@if (log.targetUser) {
|
||||||
|
<div class="user-info">
|
||||||
|
@if (log.targetUser.avatar) {
|
||||||
|
<img [src]="log.targetUser.avatar" [alt]="log.targetUser.username" class="user-avatar" />
|
||||||
|
}
|
||||||
|
<div class="user-details">
|
||||||
|
<span class="username">{{ log.targetUser.username }}</span>
|
||||||
|
<span class="user-id">{{ log.targetUserId }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (log.targetUserId) {
|
||||||
|
<span class="user-id-only">{{ log.targetUserId }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="no-user">-</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td class="details" [class.expanded]="expandedRows()[log.id]" (click)="toggleRowExpand(log.id)">
|
<td class="details" [class.expanded]="expandedRows()[log.id]" (click)="toggleRowExpand(log.id)">
|
||||||
<span class="details-content">{{ log.details ?? '-' }}</span>
|
<span class="details-content">{{ log.details ?? '-' }}</span>
|
||||||
@if (log.details && log.details.length > 50) {
|
@if (log.details && log.details.length > 50) {
|
||||||
@@ -252,6 +288,50 @@ import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-cell {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-id {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-id-only {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-user {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
.category-badge {
|
.category-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
|
|||||||
@@ -24,12 +24,20 @@ export enum AuditCategory {
|
|||||||
SECURITY = "SECURITY",
|
SECURITY = "SECURITY",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditLogUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: string;
|
id: string;
|
||||||
action: AuditAction;
|
action: AuditAction;
|
||||||
category: AuditCategory;
|
category: AuditCategory;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
user?: AuditLogUser;
|
||||||
targetUserId?: string;
|
targetUserId?: string;
|
||||||
|
targetUser?: AuditLogUser;
|
||||||
resourceType?: string;
|
resourceType?: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
details?: string;
|
details?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user