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:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

196
src/server/routers/logo.ts Normal file
View File

@@ -0,0 +1,196 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import {
getStorageProviderWithType,
createStorageProvider,
generateLogoKey,
getContentType,
isValidImageType,
type StorageProviderType,
} from '@/lib/storage'
export const logoRouter = router({
/**
* Get a pre-signed URL for uploading a project logo
*/
getUploadUrl: adminProcedure
.input(
z.object({
projectId: z.string(),
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' })
}
// Verify project exists
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true },
})
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
const key = generateLogoKey(input.projectId, 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 logo upload and update project
*/
confirmUpload: adminProcedure
.input(
z.object({
projectId: z.string(),
key: z.string(),
providerType: z.enum(['s3', 'local']),
})
)
.mutation(async ({ ctx, input }) => {
// 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 logo if exists (from its original provider)
const currentProject = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
if (currentProject?.logoKey) {
try {
const oldProvider = createStorageProvider(
(currentProject.logoProvider as StorageProviderType) || 's3'
)
await oldProvider.deleteObject(currentProject.logoKey)
} catch (error) {
// Log but don't fail if old logo deletion fails
console.warn('Failed to delete old logo:', error)
}
}
// Update project with new logo key and provider
const project = await ctx.prisma.project.update({
where: { id: input.projectId },
data: {
logoKey: input.key,
logoProvider: input.providerType,
},
select: {
id: true,
logoKey: true,
logoProvider: true,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Project',
entityId: input.projectId,
detailsJson: {
field: 'logoKey',
newValue: input.key,
provider: input.providerType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
}),
/**
* Get a project's logo URL
*/
getUrl: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
if (!project?.logoKey) {
return null
}
// Use the provider that was used when the file was stored
const providerType = (project.logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
const url = await provider.getDownloadUrl(project.logoKey)
return url
}),
/**
* Delete a project's logo
*/
delete: adminProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
if (!project?.logoKey) {
return { success: true }
}
// Delete from the provider that was used when the file was stored
const providerType = (project.logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
try {
await provider.deleteObject(project.logoKey)
} catch (error) {
console.warn('Failed to delete logo from storage:', error)
}
// Update project - clear both key and provider
await ctx.prisma.project.update({
where: { id: input.projectId },
data: {
logoKey: null,
logoProvider: null,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.projectId,
detailsJson: { field: 'logoKey' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
})