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,11 +1,19 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma as globalPrisma } from '@/lib/prisma'
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
|
||||
/** Minimal Prisma-like client that supports auditLog.create (works with PrismaClient and transaction clients). */
|
||||
type AuditPrismaClient = Pick<PrismaClient, 'auditLog'>
|
||||
|
||||
/**
|
||||
* Shared utility for creating audit log entries.
|
||||
* Wrapped in try-catch so audit failures never break the calling operation.
|
||||
*
|
||||
* @param input.prisma - Optional Prisma client instance. When omitted the global
|
||||
* singleton is used. Pass `ctx.prisma` from tRPC handlers so audit writes
|
||||
* participate in the same transaction when applicable.
|
||||
*/
|
||||
export async function logAudit(input: {
|
||||
prisma?: AuditPrismaClient
|
||||
userId?: string | null
|
||||
action: string
|
||||
entityType: string
|
||||
@@ -15,7 +23,8 @@ export async function logAudit(input: {
|
||||
userAgent?: string
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
const db = input.prisma ?? globalPrisma
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: input.userId ?? null,
|
||||
action: input.action,
|
||||
|
||||
212
src/server/utils/image-upload.ts
Normal file
212
src/server/utils/image-upload.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import { logAudit } from './audit'
|
||||
import {
|
||||
getStorageProviderWithType,
|
||||
createStorageProvider,
|
||||
getContentType,
|
||||
isValidImageType,
|
||||
type StorageProviderType,
|
||||
} from '@/lib/storage'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configuration for an image upload domain (avatar, logo, etc.)
|
||||
*
|
||||
* Each config describes how to read/write image keys for a specific entity.
|
||||
*/
|
||||
export type ImageUploadConfig<TSelectResult> = {
|
||||
/** Human-readable label used in log/error messages (e.g. "avatar", "logo") */
|
||||
label: string
|
||||
|
||||
/** Generate a storage object key for a new upload */
|
||||
generateKey: (entityId: string, fileName: string) => string
|
||||
|
||||
/** Prisma select – fetch the current image key + provider for the entity */
|
||||
findCurrent: (
|
||||
prisma: PrismaClient,
|
||||
entityId: string
|
||||
) => Promise<TSelectResult | null>
|
||||
|
||||
/** Extract the image key from the select result */
|
||||
getImageKey: (record: TSelectResult) => string | null
|
||||
|
||||
/** Extract the storage provider type from the select result */
|
||||
getProviderType: (record: TSelectResult) => StorageProviderType
|
||||
|
||||
/** Prisma update – set the new image key + provider on the entity */
|
||||
setImage: (
|
||||
prisma: PrismaClient,
|
||||
entityId: string,
|
||||
key: string,
|
||||
providerType: StorageProviderType
|
||||
) => Promise<unknown>
|
||||
|
||||
/** Prisma update – clear the image key + provider on the entity */
|
||||
clearImage: (prisma: PrismaClient, entityId: string) => Promise<unknown>
|
||||
|
||||
/** Audit log entity type (e.g. "User", "Project") */
|
||||
auditEntityType: string
|
||||
|
||||
/** Audit log field name (e.g. "profileImageKey", "logoKey") */
|
||||
auditFieldName: string
|
||||
}
|
||||
|
||||
type AuditContext = {
|
||||
userId: string
|
||||
ip: string
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a pre-signed upload URL for an image.
|
||||
*
|
||||
* Validates the content type, generates a storage key, and returns the
|
||||
* upload URL along with the key and provider type.
|
||||
*/
|
||||
export async function getImageUploadUrl(
|
||||
entityId: string,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
generateKey: (entityId: string, fileName: string) => string
|
||||
): Promise<{ uploadUrl: string; key: string; providerType: StorageProviderType }> {
|
||||
if (!isValidImageType(contentType)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
|
||||
})
|
||||
}
|
||||
|
||||
const key = generateKey(entityId, fileName)
|
||||
const resolvedContentType = getContentType(fileName)
|
||||
|
||||
const { provider, providerType } = await getStorageProviderWithType()
|
||||
const uploadUrl = await provider.getUploadUrl(key, resolvedContentType)
|
||||
|
||||
return { uploadUrl, key, providerType }
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm an image upload: verify the object exists in storage, delete the
|
||||
* previous image (if any), persist the new key, and write an audit log entry.
|
||||
*/
|
||||
export async function confirmImageUpload<TSelectResult>(
|
||||
prisma: PrismaClient,
|
||||
config: ImageUploadConfig<TSelectResult>,
|
||||
entityId: string,
|
||||
key: string,
|
||||
providerType: StorageProviderType,
|
||||
audit: AuditContext
|
||||
): Promise<void> {
|
||||
// 1. Verify upload exists in storage
|
||||
const provider = createStorageProvider(providerType)
|
||||
const exists = await provider.objectExists(key)
|
||||
if (!exists) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Upload not found. Please try uploading again.',
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Delete old image if present
|
||||
const current = await config.findCurrent(prisma, entityId)
|
||||
if (current) {
|
||||
const oldKey = config.getImageKey(current)
|
||||
if (oldKey) {
|
||||
try {
|
||||
const oldProvider = createStorageProvider(config.getProviderType(current))
|
||||
await oldProvider.deleteObject(oldKey)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete old ${config.label}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Persist new image key + provider
|
||||
await config.setImage(prisma, entityId, key, providerType)
|
||||
|
||||
// 4. Audit log
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: audit.userId,
|
||||
action: 'UPDATE',
|
||||
entityType: config.auditEntityType,
|
||||
entityId,
|
||||
detailsJson: {
|
||||
field: config.auditFieldName,
|
||||
newValue: key,
|
||||
provider: providerType,
|
||||
},
|
||||
ipAddress: audit.ip,
|
||||
userAgent: audit.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the download URL for an existing image, or null if none is set.
|
||||
*/
|
||||
export async function getImageUrl<TSelectResult>(
|
||||
prisma: PrismaClient,
|
||||
config: Pick<ImageUploadConfig<TSelectResult>, 'findCurrent' | 'getImageKey' | 'getProviderType'>,
|
||||
entityId: string
|
||||
): Promise<string | null> {
|
||||
const record = await config.findCurrent(prisma, entityId)
|
||||
if (!record) return null
|
||||
|
||||
const imageKey = config.getImageKey(record)
|
||||
if (!imageKey) return null
|
||||
|
||||
const providerType = config.getProviderType(record)
|
||||
const provider = createStorageProvider(providerType)
|
||||
return provider.getDownloadUrl(imageKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image from storage and clear the reference in the database.
|
||||
* Writes an audit log entry.
|
||||
*/
|
||||
export async function deleteImage<TSelectResult>(
|
||||
prisma: PrismaClient,
|
||||
config: ImageUploadConfig<TSelectResult>,
|
||||
entityId: string,
|
||||
audit: AuditContext
|
||||
): Promise<{ success: true }> {
|
||||
const record = await config.findCurrent(prisma, entityId)
|
||||
if (!record) return { success: true }
|
||||
|
||||
const imageKey = config.getImageKey(record)
|
||||
if (!imageKey) return { success: true }
|
||||
|
||||
// Delete from storage
|
||||
const providerType = config.getProviderType(record)
|
||||
const provider = createStorageProvider(providerType)
|
||||
try {
|
||||
await provider.deleteObject(imageKey)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete ${config.label} from storage:`, error)
|
||||
}
|
||||
|
||||
// Clear in database
|
||||
await config.clearImage(prisma, entityId)
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: audit.userId,
|
||||
action: 'DELETE',
|
||||
entityType: config.auditEntityType,
|
||||
entityId,
|
||||
detailsJson: { field: config.auditFieldName },
|
||||
ipAddress: audit.ip,
|
||||
userAgent: audit.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
Reference in New Issue
Block a user