MOPC-App/src/server/utils/image-upload.ts

213 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }
}