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,43 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure } from '../trpc'
|
||||
import { generateAvatarKey, type StorageProviderType } from '@/lib/storage'
|
||||
import {
|
||||
getStorageProviderWithType,
|
||||
createStorageProvider,
|
||||
generateAvatarKey,
|
||||
getContentType,
|
||||
isValidImageType,
|
||||
type StorageProviderType,
|
||||
} from '@/lib/storage'
|
||||
getImageUploadUrl,
|
||||
confirmImageUpload,
|
||||
getImageUrl,
|
||||
deleteImage,
|
||||
type ImageUploadConfig,
|
||||
} from '../utils/image-upload'
|
||||
|
||||
type AvatarSelect = {
|
||||
profileImageKey: string | null
|
||||
profileImageProvider: string | null
|
||||
}
|
||||
|
||||
const avatarConfig: ImageUploadConfig<AvatarSelect> = {
|
||||
label: 'avatar',
|
||||
generateKey: generateAvatarKey,
|
||||
findCurrent: (prisma, entityId) =>
|
||||
prisma.user.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { profileImageKey: true, profileImageProvider: true },
|
||||
}),
|
||||
getImageKey: (record) => record.profileImageKey,
|
||||
getProviderType: (record) =>
|
||||
(record.profileImageProvider as StorageProviderType) || 's3',
|
||||
setImage: (prisma, entityId, key, providerType) =>
|
||||
prisma.user.update({
|
||||
where: { id: entityId },
|
||||
data: { profileImageKey: key, profileImageProvider: providerType },
|
||||
}),
|
||||
clearImage: (prisma, entityId) =>
|
||||
prisma.user.update({
|
||||
where: { id: entityId },
|
||||
data: { profileImageKey: null, profileImageProvider: null },
|
||||
}),
|
||||
auditEntityType: 'User',
|
||||
auditFieldName: 'profileImageKey',
|
||||
}
|
||||
|
||||
export const avatarRouter = router({
|
||||
/**
|
||||
@@ -22,23 +51,12 @@ export const avatarRouter = 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' })
|
||||
}
|
||||
|
||||
const userId = ctx.user.id
|
||||
const key = generateAvatarKey(userId, 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(
|
||||
ctx.user.id,
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
generateAvatarKey
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -54,38 +72,15 @@ export const avatarRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id
|
||||
|
||||
// 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 avatar if exists (from its original provider)
|
||||
const currentUser = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { profileImageKey: true, profileImageProvider: true },
|
||||
await confirmImageUpload(ctx.prisma, avatarConfig, userId, input.key, input.providerType, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
if (currentUser?.profileImageKey) {
|
||||
try {
|
||||
const oldProvider = createStorageProvider(
|
||||
(currentUser.profileImageProvider as StorageProviderType) || 's3'
|
||||
)
|
||||
await oldProvider.deleteObject(currentUser.profileImageKey)
|
||||
} catch (error) {
|
||||
// Log but don't fail if old avatar deletion fails
|
||||
console.warn('Failed to delete old avatar:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Update user with new avatar key and provider
|
||||
const user = await ctx.prisma.user.update({
|
||||
// Return the updated user fields to match original API contract
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
profileImageKey: input.key,
|
||||
profileImageProvider: input.providerType,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
profileImageKey: true,
|
||||
@@ -93,23 +88,6 @@ export const avatarRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
detailsJson: {
|
||||
field: 'profileImageKey',
|
||||
newValue: input.key,
|
||||
provider: input.providerType,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return user
|
||||
}),
|
||||
|
||||
@@ -117,71 +95,17 @@ export const avatarRouter = router({
|
||||
* Get the current user's avatar URL
|
||||
*/
|
||||
getUrl: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.user.id
|
||||
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { profileImageKey: true, profileImageProvider: true },
|
||||
})
|
||||
|
||||
if (!user?.profileImageKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the provider that was used when the file was stored
|
||||
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
const url = await provider.getDownloadUrl(user.profileImageKey)
|
||||
|
||||
return url
|
||||
return getImageUrl(ctx.prisma, avatarConfig, ctx.user.id)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete the current user's avatar
|
||||
*/
|
||||
delete: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.user.id
|
||||
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { profileImageKey: true, profileImageProvider: true },
|
||||
return deleteImage(ctx.prisma, avatarConfig, ctx.user.id, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
if (!user?.profileImageKey) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Delete from the provider that was used when the file was stored
|
||||
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
try {
|
||||
await provider.deleteObject(user.profileImageKey)
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete avatar from storage:', error)
|
||||
}
|
||||
|
||||
// Update user - clear both key and provider
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
profileImageKey: null,
|
||||
profileImageProvider: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
detailsJson: { field: 'profileImageKey' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user