2026-02-14 15:26:42 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
2026-02-21 18:50:29 +01:00
|
|
|
import { Prisma } from '@prisma/client'
|
2026-02-14 15:26:42 +01:00
|
|
|
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'
|
|
|
|
|
|
2026-02-21 18:50:29 +01:00
|
|
|
// 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<typeof accessRuleSchema>
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<boolean> {
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
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),
|
2026-02-21 18:50:29 +01:00
|
|
|
perPage: z.number().int().min(1).max(100).default(50),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const where: Record<string, unknown> = {}
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-21 18:50:29 +01:00
|
|
|
* Get resources accessible to the current user (jury/mentor/observer view)
|
2026-02-14 15:26:42 +01:00
|
|
|
*/
|
|
|
|
|
myResources: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
programId: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const where: Record<string, unknown> = {
|
|
|
|
|
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' }],
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-21 18:50:29 +01:00
|
|
|
// 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)
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
2026-02-21 18:50:29 +01:00
|
|
|
|
|
|
|
|
return { resources: accessible }
|
2026-02-14 15:26:42 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2026-02-21 18:50:29 +01:00
|
|
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
|
|
|
|
if (!isAdmin) {
|
2026-02-14 15:26:42 +01:00
|
|
|
if (!resource.isPublished) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'This resource is not available',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 18:50:29 +01:00
|
|
|
const allowed = await canUserAccessResource(
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
ctx.user.id,
|
|
|
|
|
ctx.user.role,
|
|
|
|
|
resource.accessJson,
|
|
|
|
|
)
|
|
|
|
|
if (!allowed) {
|
2026-02-14 15:26:42 +01:00
|
|
|
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',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 18:50:29 +01:00
|
|
|
const allowed = await canUserAccessResource(
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
ctx.user.id,
|
|
|
|
|
ctx.user.role,
|
|
|
|
|
resource.accessJson,
|
|
|
|
|
)
|
|
|
|
|
if (!allowed) {
|
2026-02-14 15:26:42 +01:00
|
|
|
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 }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-21 18:50:29 +01:00
|
|
|
/**
|
|
|
|
|
* 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 }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-02-21 18:50:29 +01:00
|
|
|
accessJson: accessRuleSchema.array().nullable().optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
externalUrl: z.string().url().optional(),
|
2026-02-21 18:50:29 +01:00
|
|
|
coverImageKey: z.string().optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
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 }) => {
|
2026-02-21 18:50:29 +01:00
|
|
|
const { accessJson, ...rest } = input
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
const resource = await ctx.prisma.learningResource.create({
|
|
|
|
|
data: {
|
2026-02-21 18:50:29 +01:00
|
|
|
...rest,
|
|
|
|
|
accessJson: accessJson === null ? Prisma.JsonNull : accessJson ?? undefined,
|
2026-02-14 15:26:42 +01:00
|
|
|
createdById: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'LearningResource',
|
|
|
|
|
entityId: resource.id,
|
2026-02-21 18:50:29 +01:00
|
|
|
detailsJson: { title: input.title },
|
2026-02-14 15:26:42 +01:00
|
|
|
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(),
|
2026-02-21 18:50:29 +01:00
|
|
|
description: z.string().optional().nullable(),
|
2026-02-14 15:26:42 +01:00
|
|
|
contentJson: z.any().optional(), // BlockNote document structure
|
2026-02-21 18:50:29 +01:00
|
|
|
accessJson: accessRuleSchema.array().nullable().optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
externalUrl: z.string().url().optional().nullable(),
|
2026-02-21 18:50:29 +01:00
|
|
|
coverImageKey: z.string().optional().nullable(),
|
|
|
|
|
programId: z.string().nullable().optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
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 }) => {
|
2026-02-21 18:50:29 +01:00
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-02-21 18:50:29 +01:00
|
|
|
detailsJson: rest,
|
2026-02-14 15:26:42 +01:00
|
|
|
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 }
|
|
|
|
|
}),
|
|
|
|
|
})
|