213 lines
6.2 KiB
TypeScript
213 lines
6.2 KiB
TypeScript
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 }
|
||
}
|