import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure, } from '../trpc' import { getPresignedUrl } from '@/lib/minio' // Bucket for learning resources export const LEARNING_BUCKET = 'mopc-learning' export const learningResourceRouter = router({ /** * List all resources (admin view) */ list: adminProcedure .input( z.object({ programId: z.string().optional(), resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(), cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(), isPublished: z.boolean().optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(20), }) ) .query(async ({ ctx, input }) => { const where: Record = {} if (input.programId !== undefined) { where.programId = input.programId } if (input.resourceType) { where.resourceType = input.resourceType } if (input.cohortLevel) { where.cohortLevel = input.cohortLevel } if (input.isPublished !== undefined) { where.isPublished = input.isPublished } const [data, total] = await Promise.all([ ctx.prisma.learningResource.findMany({ where, include: { program: { select: { id: true, name: true, year: true } }, createdBy: { select: { id: true, name: true, email: true } }, _count: { select: { accessLogs: true } }, }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], skip: (input.page - 1) * input.perPage, take: input.perPage, }), ctx.prisma.learningResource.count({ where }), ]) return { data, total, page: input.page, perPage: input.perPage, totalPages: Math.ceil(total / input.perPage), } }), /** * Get resources accessible to the current user (jury view) */ myResources: protectedProcedure .input( z.object({ programId: z.string().optional(), resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(), }) ) .query(async ({ ctx, input }) => { // Determine user's cohort level based on their assignments const assignments = await ctx.prisma.assignment.findMany({ where: { userId: ctx.user.id }, include: { project: { select: { roundProjects: { select: { status: true }, orderBy: { addedAt: 'desc' }, take: 1, }, }, }, }, }) // Determine highest cohort level let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' for (const assignment of assignments) { const rpStatus = assignment.project.roundProjects[0]?.status if (rpStatus === 'FINALIST') { userCohortLevel = 'FINALIST' break } if (rpStatus === 'SEMIFINALIST') { userCohortLevel = 'SEMIFINALIST' } } // Build query based on cohort level const cohortLevels = ['ALL'] if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') { cohortLevels.push('SEMIFINALIST') } if (userCohortLevel === 'FINALIST') { cohortLevels.push('FINALIST') } const where: Record = { isPublished: true, cohortLevel: { in: cohortLevels }, } if (input.programId) { where.OR = [{ programId: input.programId }, { programId: null }] } if (input.resourceType) { where.resourceType = input.resourceType } const resources = await ctx.prisma.learningResource.findMany({ where, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], }) return { resources, userCohortLevel, } }), /** * Get a single resource by ID */ get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const resource = await ctx.prisma.learningResource.findUniqueOrThrow({ where: { id: input.id }, include: { program: { select: { id: true, name: true, year: true } }, createdBy: { select: { id: true, name: true, email: true } }, }, }) // Check access for non-admins if (ctx.user.role === 'JURY_MEMBER') { if (!resource.isPublished) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This resource is not available', }) } // Check cohort level access const assignments = await ctx.prisma.assignment.findMany({ where: { userId: ctx.user.id }, include: { project: { select: { roundProjects: { select: { status: true }, orderBy: { addedAt: 'desc' as const }, take: 1, }, }, }, }, }) let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' for (const assignment of assignments) { const rpStatus = assignment.project.roundProjects[0]?.status if (rpStatus === 'FINALIST') { userCohortLevel = 'FINALIST' break } if (rpStatus === 'SEMIFINALIST') { userCohortLevel = 'SEMIFINALIST' } } const accessibleLevels = ['ALL'] if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') { accessibleLevels.push('SEMIFINALIST') } if (userCohortLevel === 'FINALIST') { accessibleLevels.push('FINALIST') } if (!accessibleLevels.includes(resource.cohortLevel)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this resource', }) } } return resource }), /** * Get download URL for a resource file * Checks cohort level access for non-admin users */ getDownloadUrl: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const resource = await ctx.prisma.learningResource.findUniqueOrThrow({ where: { id: input.id }, }) if (!resource.bucket || !resource.objectKey) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'This resource does not have a file', }) } // Check access for non-admins const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) if (!isAdmin) { if (!resource.isPublished) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This resource is not available', }) } // Check cohort level access const assignments = await ctx.prisma.assignment.findMany({ where: { userId: ctx.user.id }, include: { project: { select: { roundProjects: { select: { status: true }, orderBy: { addedAt: 'desc' as const }, take: 1, }, }, }, }, }) let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' for (const assignment of assignments) { const rpStatus = assignment.project.roundProjects[0]?.status if (rpStatus === 'FINALIST') { userCohortLevel = 'FINALIST' break } if (rpStatus === 'SEMIFINALIST') { userCohortLevel = 'SEMIFINALIST' } } const accessibleLevels = ['ALL'] if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') { accessibleLevels.push('SEMIFINALIST') } if (userCohortLevel === 'FINALIST') { accessibleLevels.push('FINALIST') } if (!accessibleLevels.includes(resource.cohortLevel)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this resource', }) } } // Log access await ctx.prisma.resourceAccess.create({ data: { resourceId: resource.id, userId: ctx.user.id, ipAddress: ctx.ip, }, }) const url = await getPresignedUrl(resource.bucket, resource.objectKey, 'GET', 900) return { url } }), /** * Create a new resource (admin only) */ create: adminProcedure .input( z.object({ programId: z.string().nullable(), title: z.string().min(1).max(255), description: z.string().optional(), contentJson: z.any().optional(), // BlockNote document structure resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']), cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).default('ALL'), externalUrl: z.string().url().optional(), sortOrder: z.number().int().default(0), isPublished: z.boolean().default(false), // File info (set after upload) fileName: z.string().optional(), mimeType: z.string().optional(), size: z.number().int().optional(), bucket: z.string().optional(), objectKey: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { const resource = await ctx.prisma.learningResource.create({ data: { ...input, createdById: ctx.user.id, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'CREATE', entityType: 'LearningResource', entityId: resource.id, detailsJson: { title: input.title, resourceType: input.resourceType }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return resource }), /** * Update a resource (admin only) */ update: adminProcedure .input( z.object({ id: z.string(), title: z.string().min(1).max(255).optional(), description: z.string().optional(), contentJson: z.any().optional(), // BlockNote document structure resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(), cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(), externalUrl: z.string().url().optional().nullable(), sortOrder: z.number().int().optional(), isPublished: z.boolean().optional(), // File info (set after upload) fileName: z.string().optional(), mimeType: z.string().optional(), size: z.number().int().optional(), bucket: z.string().optional(), objectKey: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input const resource = await ctx.prisma.learningResource.update({ where: { id }, data, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE', entityType: 'LearningResource', entityId: id, detailsJson: data, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return resource }), /** * Delete a resource (admin only) */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const resource = await ctx.prisma.learningResource.delete({ where: { id: input.id }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'DELETE', entityType: 'LearningResource', entityId: input.id, detailsJson: { title: resource.title }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return resource }), /** * Get upload URL for a resource file (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 = `resources/${timestamp}-${sanitizedName}` const url = await getPresignedUrl(LEARNING_BUCKET, objectKey, 'PUT', 3600) return { url, bucket: LEARNING_BUCKET, objectKey, } }), /** * Get access statistics for a resource (admin only) */ getStats: adminProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const [totalViews, uniqueUsers, recentAccess] = await Promise.all([ ctx.prisma.resourceAccess.count({ where: { resourceId: input.id }, }), ctx.prisma.resourceAccess.groupBy({ by: ['userId'], where: { resourceId: input.id }, }), ctx.prisma.resourceAccess.findMany({ where: { resourceId: input.id }, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { accessedAt: 'desc' }, take: 10, }), ]) return { totalViews, uniqueUsers: uniqueUsers.length, recentAccess, } }), /** * Reorder resources (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.learningResource.update({ where: { id: item.id }, data: { sortOrder: item.sortOrder }, }) ) ) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'REORDER', entityType: 'LearningResource', detailsJson: { count: input.items.length }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { success: true } }), })