356 lines
9.3 KiB
TypeScript
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 }
|
|
}),
|
|
})
|