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