188 lines
5.2 KiB
TypeScript
188 lines
5.2 KiB
TypeScript
|
|
import { z } from 'zod'
|
||
|
|
import { TRPCError } from '@trpc/server'
|
||
|
|
import { router, protectedProcedure } from '../trpc'
|
||
|
|
import {
|
||
|
|
getStorageProviderWithType,
|
||
|
|
createStorageProvider,
|
||
|
|
generateAvatarKey,
|
||
|
|
getContentType,
|
||
|
|
isValidImageType,
|
||
|
|
type StorageProviderType,
|
||
|
|
} from '@/lib/storage'
|
||
|
|
|
||
|
|
export const avatarRouter = router({
|
||
|
|
/**
|
||
|
|
* Get a pre-signed URL for uploading an avatar
|
||
|
|
*/
|
||
|
|
getUploadUrl: protectedProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
fileName: z.string(),
|
||
|
|
contentType: z.string(),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
// Validate content type
|
||
|
|
if (!isValidImageType(input.contentType)) {
|
||
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP' })
|
||
|
|
}
|
||
|
|
|
||
|
|
const userId = ctx.user.id
|
||
|
|
const key = generateAvatarKey(userId, input.fileName)
|
||
|
|
const contentType = getContentType(input.fileName)
|
||
|
|
|
||
|
|
const { provider, providerType } = await getStorageProviderWithType()
|
||
|
|
const uploadUrl = await provider.getUploadUrl(key, contentType)
|
||
|
|
|
||
|
|
return {
|
||
|
|
uploadUrl,
|
||
|
|
key,
|
||
|
|
providerType, // Return so client can pass it back on confirm
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Confirm avatar upload and update user profile
|
||
|
|
*/
|
||
|
|
confirmUpload: protectedProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
key: z.string(),
|
||
|
|
providerType: z.enum(['s3', 'local']),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const userId = ctx.user.id
|
||
|
|
|
||
|
|
// Use the provider that was used for upload
|
||
|
|
const provider = createStorageProvider(input.providerType)
|
||
|
|
const exists = await provider.objectExists(input.key)
|
||
|
|
if (!exists) {
|
||
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' })
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete old avatar if exists (from its original provider)
|
||
|
|
const currentUser = await ctx.prisma.user.findUnique({
|
||
|
|
where: { id: userId },
|
||
|
|
select: { profileImageKey: true, profileImageProvider: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
if (currentUser?.profileImageKey) {
|
||
|
|
try {
|
||
|
|
const oldProvider = createStorageProvider(
|
||
|
|
(currentUser.profileImageProvider as StorageProviderType) || 's3'
|
||
|
|
)
|
||
|
|
await oldProvider.deleteObject(currentUser.profileImageKey)
|
||
|
|
} catch (error) {
|
||
|
|
// Log but don't fail if old avatar deletion fails
|
||
|
|
console.warn('Failed to delete old avatar:', error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update user with new avatar key and provider
|
||
|
|
const user = await ctx.prisma.user.update({
|
||
|
|
where: { id: userId },
|
||
|
|
data: {
|
||
|
|
profileImageKey: input.key,
|
||
|
|
profileImageProvider: input.providerType,
|
||
|
|
},
|
||
|
|
select: {
|
||
|
|
id: true,
|
||
|
|
profileImageKey: true,
|
||
|
|
profileImageProvider: true,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await ctx.prisma.auditLog.create({
|
||
|
|
data: {
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'UPDATE',
|
||
|
|
entityType: 'User',
|
||
|
|
entityId: userId,
|
||
|
|
detailsJson: {
|
||
|
|
field: 'profileImageKey',
|
||
|
|
newValue: input.key,
|
||
|
|
provider: input.providerType,
|
||
|
|
},
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
return user
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the current user's avatar URL
|
||
|
|
*/
|
||
|
|
getUrl: protectedProcedure.query(async ({ ctx }) => {
|
||
|
|
const userId = ctx.user.id
|
||
|
|
|
||
|
|
const user = await ctx.prisma.user.findUnique({
|
||
|
|
where: { id: userId },
|
||
|
|
select: { profileImageKey: true, profileImageProvider: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!user?.profileImageKey) {
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use the provider that was used when the file was stored
|
||
|
|
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
|
||
|
|
const provider = createStorageProvider(providerType)
|
||
|
|
const url = await provider.getDownloadUrl(user.profileImageKey)
|
||
|
|
|
||
|
|
return url
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Delete the current user's avatar
|
||
|
|
*/
|
||
|
|
delete: protectedProcedure.mutation(async ({ ctx }) => {
|
||
|
|
const userId = ctx.user.id
|
||
|
|
|
||
|
|
const user = await ctx.prisma.user.findUnique({
|
||
|
|
where: { id: userId },
|
||
|
|
select: { profileImageKey: true, profileImageProvider: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!user?.profileImageKey) {
|
||
|
|
return { success: true }
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete from the provider that was used when the file was stored
|
||
|
|
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
|
||
|
|
const provider = createStorageProvider(providerType)
|
||
|
|
try {
|
||
|
|
await provider.deleteObject(user.profileImageKey)
|
||
|
|
} catch (error) {
|
||
|
|
console.warn('Failed to delete avatar from storage:', error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update user - clear both key and provider
|
||
|
|
await ctx.prisma.user.update({
|
||
|
|
where: { id: userId },
|
||
|
|
data: {
|
||
|
|
profileImageKey: null,
|
||
|
|
profileImageProvider: null,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await ctx.prisma.auditLog.create({
|
||
|
|
data: {
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'DELETE',
|
||
|
|
entityType: 'User',
|
||
|
|
entityId: userId,
|
||
|
|
detailsJson: { field: 'profileImageKey' },
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
return { success: true }
|
||
|
|
}),
|
||
|
|
})
|