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

509 lines
14 KiB
TypeScript
Raw Normal View History

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