import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure, } from '../trpc' import { getPresignedUrl } from '@/lib/minio' import { logAudit } from '../utils/audit' // Bucket for learning resources export const LEARNING_BUCKET = 'mopc-learning' // Access rule schema for fine-grained access control const accessRuleSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('everyone') }), z.object({ type: z.literal('roles'), roles: z.array(z.string()) }), z.object({ type: z.literal('jury_group'), juryGroupIds: z.array(z.string()) }), z.object({ type: z.literal('round'), roundIds: z.array(z.string()) }), ]) type AccessRule = z.infer /** * Evaluate whether a user can access a resource based on its accessJson rules. * null/empty = everyone. Rules are OR-combined (match ANY rule = access). */ async function canUserAccessResource( prisma: { juryGroupMember: { findFirst: Function }; assignment: { findFirst: Function } }, userId: string, userRole: string, accessJson: unknown, ): Promise { // null or empty = everyone if (!accessJson) return true let rules: AccessRule[] try { const parsed = accessJson as unknown[] if (!Array.isArray(parsed) || parsed.length === 0) return true rules = parsed as AccessRule[] } catch { return true } for (const rule of rules) { if (rule.type === 'everyone') return true if (rule.type === 'roles') { if (rule.roles.includes(userRole)) return true } if (rule.type === 'jury_group') { const membership = await prisma.juryGroupMember.findFirst({ where: { userId, juryGroupId: { in: rule.juryGroupIds }, }, }) if (membership) return true } if (rule.type === 'round') { const assignment = await prisma.assignment.findFirst({ where: { userId, roundId: { in: rule.roundIds }, }, }) if (assignment) return true } } return false } export const learningResourceRouter = router({ /** * List all resources (admin view) */ list: adminProcedure .input( z.object({ programId: z.string().optional(), isPublished: 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.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/mentor/observer view) */ myResources: protectedProcedure .input( z.object({ programId: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where: Record = { isPublished: true, } if (input.programId) { where.OR = [{ programId: input.programId }, { programId: null }] } const resources = await ctx.prisma.learningResource.findMany({ where, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], }) // Filter by access rules in application code (small dataset) const accessible = [] for (const resource of resources) { const allowed = await canUserAccessResource( ctx.prisma, ctx.user.id, ctx.user.role, resource.accessJson, ) if (allowed) accessible.push(resource) } return { resources: accessible } }), /** * 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 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', }) } const allowed = await canUserAccessResource( ctx.prisma, ctx.user.id, ctx.user.role, resource.accessJson, ) if (!allowed) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this resource', }) } } return resource }), /** * Get download URL for a resource file */ 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', }) } const allowed = await canUserAccessResource( ctx.prisma, ctx.user.id, ctx.user.role, resource.accessJson, ) if (!allowed) { 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 } }), /** * Log access when user opens a resource detail page */ logAccess: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.prisma.resourceAccess.create({ data: { resourceId: input.id, userId: ctx.user.id, ipAddress: ctx.ip, }, }) return { success: true } }), /** * 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 accessJson: accessRuleSchema.array().nullable().optional(), externalUrl: z.string().url().optional(), coverImageKey: z.string().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 { accessJson, ...rest } = input const resource = await ctx.prisma.learningResource.create({ data: { ...rest, accessJson: accessJson === null ? Prisma.JsonNull : accessJson ?? undefined, createdById: ctx.user.id, }, }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'LearningResource', entityId: resource.id, detailsJson: { title: input.title }, 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().nullable(), contentJson: z.any().optional(), // BlockNote document structure accessJson: accessRuleSchema.array().nullable().optional(), externalUrl: z.string().url().optional().nullable(), coverImageKey: z.string().optional().nullable(), programId: z.string().nullable().optional(), 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, accessJson, ...rest } = input // Prisma requires Prisma.JsonNull for nullable JSON fields instead of raw null const data = { ...rest, ...(accessJson !== undefined && { accessJson: accessJson === null ? Prisma.JsonNull : accessJson, }), } const resource = await ctx.prisma.learningResource.update({ where: { id }, data, }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'LearningResource', entityId: id, detailsJson: rest, 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 logAudit({ prisma: ctx.prisma, 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 logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'REORDER', entityType: 'LearningResource', detailsJson: { count: input.items.length }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { success: true } }), })