Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
187
src/server/routers/avatar.ts
Normal file
187
src/server/routers/avatar.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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 }
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user