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 = { /** 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 /** 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 /** Prisma update – clear the image key + provider on the entity */ clearImage: (prisma: PrismaClient, entityId: string) => Promise /** 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( prisma: PrismaClient, config: ImageUploadConfig, entityId: string, key: string, providerType: StorageProviderType, audit: AuditContext ): Promise { // 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( prisma: PrismaClient, config: Pick, 'findCurrent' | 'getImageKey' | 'getProviderType'>, entityId: string ): Promise { 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( prisma: PrismaClient, config: ImageUploadConfig, 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 } }