MOPC-App/src/server/routers/partner.ts

356 lines
9.3 KiB
TypeScript

import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
// Bucket for partner logos
export const PARTNER_BUCKET = 'mopc-partners'
export const partnerRouter = router({
/**
* List all partners (admin view)
*/
list: adminProcedure
.input(
z.object({
programId: z.string().optional(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).optional(),
isActive: z.boolean().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.programId !== undefined) {
where.programId = input.programId
}
if (input.partnerType) {
where.partnerType = input.partnerType
}
if (input.visibility) {
where.visibility = input.visibility
}
if (input.isActive !== undefined) {
where.isActive = input.isActive
}
const [data, total] = await Promise.all([
ctx.prisma.partner.findMany({
where,
include: {
program: { select: { id: true, name: true, year: true } },
},
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.partner.count({ where }),
])
return {
data,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
/**
* Get partners visible to jury members
*/
getJuryVisible: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
isActive: true,
visibility: { in: ['JURY_VISIBLE', 'PUBLIC'] },
}
if (input.programId) {
where.OR = [{ programId: input.programId }, { programId: null }]
}
if (input.partnerType) {
where.partnerType = input.partnerType
}
return ctx.prisma.partner.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
})
}),
/**
* Get public partners (for public website)
*/
getPublic: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
isActive: true,
visibility: 'PUBLIC',
}
if (input.programId) {
where.OR = [{ programId: input.programId }, { programId: null }]
}
if (input.partnerType) {
where.partnerType = input.partnerType
}
return ctx.prisma.partner.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
})
}),
/**
* Get a single partner by ID
*/
get: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.partner.findUniqueOrThrow({
where: { id: input.id },
include: {
program: { select: { id: true, name: true, year: true } },
},
})
}),
/**
* Get logo URL for a partner
*/
getLogoUrl: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const partner = await ctx.prisma.partner.findUniqueOrThrow({
where: { id: input.id },
})
if (!partner.logoBucket || !partner.logoObjectKey) {
return { url: null }
}
const url = await getPresignedUrl(partner.logoBucket, partner.logoObjectKey, 'GET', 900)
return { url }
}),
/**
* Create a new partner (admin only)
*/
create: adminProcedure
.input(
z.object({
programId: z.string().nullable(),
name: z.string().min(1).max(255),
description: z.string().optional(),
website: z.string().url().optional(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).default('PARTNER'),
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).default('ADMIN_ONLY'),
sortOrder: z.number().int().default(0),
isActive: z.boolean().default(true),
// Logo info (set after upload)
logoFileName: z.string().optional(),
logoBucket: z.string().optional(),
logoObjectKey: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const partner = await ctx.prisma.partner.create({
data: input,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Partner',
entityId: partner.id,
detailsJson: { name: input.name, partnerType: input.partnerType },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return partner
}),
/**
* Update a partner (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().optional().nullable(),
website: z.string().url().optional().nullable(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).optional(),
sortOrder: z.number().int().optional(),
isActive: z.boolean().optional(),
// Logo info
logoFileName: z.string().optional().nullable(),
logoBucket: z.string().optional().nullable(),
logoObjectKey: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const partner = await ctx.prisma.partner.update({
where: { id },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Partner',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return partner
}),
/**
* Delete a partner (admin only)
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const partner = await ctx.prisma.partner.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Partner',
entityId: input.id,
detailsJson: { name: partner.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return partner
}),
/**
* Get upload URL for a partner logo (admin only)
*/
getUploadUrl: adminProcedure
.input(
z.object({
fileName: z.string(),
mimeType: z.string(),
})
)
.mutation(async ({ input }) => {
const timestamp = Date.now()
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
const objectKey = `logos/${timestamp}-${sanitizedName}`
const url = await getPresignedUrl(PARTNER_BUCKET, objectKey, 'PUT', 3600)
return {
url,
bucket: PARTNER_BUCKET,
objectKey,
}
}),
/**
* Reorder partners (admin only)
*/
reorder: adminProcedure
.input(
z.object({
items: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.items.map((item) =>
ctx.prisma.partner.update({
where: { id: item.id },
data: { sortOrder: item.sortOrder },
})
)
)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'REORDER',
entityType: 'Partner',
detailsJson: { count: input.items.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
/**
* Bulk update visibility (admin only)
*/
bulkUpdateVisibility: adminProcedure
.input(
z.object({
ids: z.array(z.string()),
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.partner.updateMany({
where: { id: { in: input.ids } },
data: { visibility: input.visibility },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_UPDATE',
entityType: 'Partner',
detailsJson: { ids: input.ids, visibility: input.visibility },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { updated: input.ids.length }
}),
})