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 = {} 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 = { 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 = { 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 } }), })