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:
@@ -1,14 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
|
||||
import {
|
||||
getStorageProviderWithType,
|
||||
createStorageProvider,
|
||||
generateLogoKey,
|
||||
getContentType,
|
||||
isValidImageType,
|
||||
type StorageProviderType,
|
||||
} from '@/lib/storage'
|
||||
getImageUploadUrl,
|
||||
confirmImageUpload,
|
||||
getImageUrl,
|
||||
deleteImage,
|
||||
type ImageUploadConfig,
|
||||
} from '../utils/image-upload'
|
||||
|
||||
type LogoSelect = {
|
||||
logoKey: string | null
|
||||
logoProvider: string | null
|
||||
}
|
||||
|
||||
const logoConfig: ImageUploadConfig<LogoSelect> = {
|
||||
label: 'logo',
|
||||
generateKey: generateLogoKey,
|
||||
findCurrent: (prisma, entityId) =>
|
||||
prisma.project.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
}),
|
||||
getImageKey: (record) => record.logoKey,
|
||||
getProviderType: (record) =>
|
||||
(record.logoProvider as StorageProviderType) || 's3',
|
||||
setImage: (prisma, entityId, key, providerType) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: key, logoProvider: providerType },
|
||||
}),
|
||||
clearImage: (prisma, entityId) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: null, logoProvider: null },
|
||||
}),
|
||||
auditEntityType: 'Project',
|
||||
auditFieldName: 'logoKey',
|
||||
}
|
||||
|
||||
export const logoRouter = router({
|
||||
/**
|
||||
@@ -23,11 +53,6 @@ export const logoRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate content type
|
||||
if (!isValidImageType(input.contentType)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP' })
|
||||
}
|
||||
|
||||
// Verify project exists
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
@@ -38,17 +63,12 @@ export const logoRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||
}
|
||||
|
||||
const key = generateLogoKey(input.projectId, input.fileName)
|
||||
const contentType = getContentType(input.fileName)
|
||||
|
||||
const { provider, providerType } = await getStorageProviderWithType()
|
||||
const uploadUrl = await provider.getUploadUrl(key, contentType)
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
key,
|
||||
providerType, // Return so client can pass it back on confirm
|
||||
}
|
||||
return getImageUploadUrl(
|
||||
input.projectId,
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
generateLogoKey
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -63,38 +83,22 @@ export const logoRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Use the provider that was used for upload
|
||||
const provider = createStorageProvider(input.providerType)
|
||||
const exists = await provider.objectExists(input.key)
|
||||
if (!exists) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' })
|
||||
}
|
||||
|
||||
// Delete old logo if exists (from its original provider)
|
||||
const currentProject = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
})
|
||||
|
||||
if (currentProject?.logoKey) {
|
||||
try {
|
||||
const oldProvider = createStorageProvider(
|
||||
(currentProject.logoProvider as StorageProviderType) || 's3'
|
||||
)
|
||||
await oldProvider.deleteObject(currentProject.logoKey)
|
||||
} catch (error) {
|
||||
// Log but don't fail if old logo deletion fails
|
||||
console.warn('Failed to delete old logo:', error)
|
||||
await confirmImageUpload(
|
||||
ctx.prisma,
|
||||
logoConfig,
|
||||
input.projectId,
|
||||
input.key,
|
||||
input.providerType,
|
||||
{
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update project with new logo key and provider
|
||||
const project = await ctx.prisma.project.update({
|
||||
// Return the updated project fields to match original API contract
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
data: {
|
||||
logoKey: input.key,
|
||||
logoProvider: input.providerType,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
logoKey: true,
|
||||
@@ -102,23 +106,6 @@ export const logoRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: {
|
||||
field: 'logoKey',
|
||||
newValue: input.key,
|
||||
provider: input.providerType,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return project
|
||||
}),
|
||||
|
||||
@@ -128,21 +115,7 @@ export const logoRouter = router({
|
||||
getUrl: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
})
|
||||
|
||||
if (!project?.logoKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the provider that was used when the file was stored
|
||||
const providerType = (project.logoProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
const url = await provider.getDownloadUrl(project.logoKey)
|
||||
|
||||
return url
|
||||
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -151,46 +124,10 @@ export const logoRouter = router({
|
||||
delete: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
if (!project?.logoKey) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Delete from the provider that was used when the file was stored
|
||||
const providerType = (project.logoProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
try {
|
||||
await provider.deleteObject(project.logoKey)
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete logo from storage:', error)
|
||||
}
|
||||
|
||||
// Update project - clear both key and provider
|
||||
await ctx.prisma.project.update({
|
||||
where: { id: input.projectId },
|
||||
data: {
|
||||
logoKey: null,
|
||||
logoProvider: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: { field: 'logoKey' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user