Platform review round 2: audit logging migration, nav unification, DB indexes, and UI polish
- Migrate ~41 inline audit log calls to shared logAudit() utility across all routers - Add transaction-aware prisma parameter to logAudit() for atomic operations - Unify jury/mentor/observer navigation into shared RoleNav component - Add composite DB indexes (Evaluation, GracePeriod, AuditLog) for query performance - Fix profile page: consolidate dual save buttons, proper useEffect initialization - Enhance auth error page with MOPC branding and navigation - Improve observer dashboard with prominent read-only badge - Fix DI-3: fetch projects before bulk status update for accurate notifications - Remove unused aiBoost field from smart-assignment scoring - Add shared image-upload utility and structured logger module Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const projectRouter = router({
|
||||
/**
|
||||
@@ -297,25 +298,27 @@ export const projectRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { metadataJson, ...rest } = input
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
...rest,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
...rest,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
entityId: created.id,
|
||||
detailsJson: { title: input.title, roundId: input.roundId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return project
|
||||
@@ -457,16 +460,15 @@ export const projectRouter = router({
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Project',
|
||||
entityId: id,
|
||||
detailsJson: { ...data, status, metadataJson } as Prisma.InputJsonValue,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Project',
|
||||
entityId: id,
|
||||
detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return project
|
||||
@@ -478,21 +480,26 @@ export const projectRouter = router({
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const project = await ctx.prisma.project.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
const target = await tx.project.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Project',
|
||||
entityId: input.id,
|
||||
detailsJson: { title: project.title },
|
||||
detailsJson: { title: target.title },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return tx.project.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
})
|
||||
|
||||
return project
|
||||
@@ -559,15 +566,14 @@ export const projectRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -617,40 +623,42 @@ export const projectRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Fetch matching projects BEFORE update so notifications match actually-updated records
|
||||
const [projects, round] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
roundId: input.roundId,
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
}),
|
||||
ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
|
||||
}),
|
||||
])
|
||||
|
||||
const matchingIds = projects.map((p) => p.id)
|
||||
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
id: { in: matchingIds },
|
||||
roundId: input.roundId,
|
||||
},
|
||||
data: { status: input.status },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE_STATUS',
|
||||
entityType: 'Project',
|
||||
detailsJson: { ids: input.ids, roundId: input.roundId, status: input.status },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE_STATUS',
|
||||
entityType: 'Project',
|
||||
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: updated.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Get round details including configured notification type
|
||||
const [projects, round] = await Promise.all([
|
||||
input.ids.length > 0
|
||||
? ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.ids } },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
|
||||
}),
|
||||
])
|
||||
|
||||
// Helper to get notification title based on type
|
||||
const getNotificationTitle = (type: string): string => {
|
||||
const titles: Record<string, string> = {
|
||||
|
||||
Reference in New Issue
Block a user