Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -1,493 +1,493 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
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'
|
||||
|
||||
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: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Determine highest cohort level
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (projectStatus === '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: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (projectStatus === '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: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (projectStatus === '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 logAudit({
|
||||
prisma: ctx.prisma,
|
||||
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 logAudit({
|
||||
prisma: ctx.prisma,
|
||||
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 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 }
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
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'
|
||||
|
||||
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: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Determine highest cohort level
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (projectStatus === '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: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (projectStatus === '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: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (projectStatus === '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 logAudit({
|
||||
prisma: ctx.prisma,
|
||||
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 logAudit({
|
||||
prisma: ctx.prisma,
|
||||
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 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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user