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,99 +1,99 @@
|
||||
import { router } from '../trpc'
|
||||
import { programRouter } from './program'
|
||||
import { projectRouter } from './project'
|
||||
import { userRouter } from './user'
|
||||
import { assignmentRouter } from './assignment'
|
||||
import { evaluationRouter } from './evaluation'
|
||||
import { fileRouter } from './file'
|
||||
import { exportRouter } from './export'
|
||||
import { auditRouter } from './audit'
|
||||
import { settingsRouter } from './settings'
|
||||
import { gracePeriodRouter } from './gracePeriod'
|
||||
// Phase 2 routers
|
||||
import { learningResourceRouter } from './learningResource'
|
||||
import { partnerRouter } from './partner'
|
||||
import { notionImportRouter } from './notion-import'
|
||||
import { typeformImportRouter } from './typeform-import'
|
||||
// Phase 2B routers
|
||||
import { tagRouter } from './tag'
|
||||
import { applicantRouter } from './applicant'
|
||||
import { liveVotingRouter } from './live-voting'
|
||||
import { analyticsRouter } from './analytics'
|
||||
// Storage routers
|
||||
import { avatarRouter } from './avatar'
|
||||
import { logoRouter } from './logo'
|
||||
// Applicant system routers
|
||||
import { applicationRouter } from './application'
|
||||
import { mentorRouter } from './mentor'
|
||||
import { filteringRouter } from './filtering'
|
||||
import { specialAwardRouter } from './specialAward'
|
||||
import { notificationRouter } from './notification'
|
||||
// Feature expansion routers
|
||||
import { messageRouter } from './message'
|
||||
import { webhookRouter } from './webhook'
|
||||
import { projectPoolRouter } from './project-pool'
|
||||
import { wizardTemplateRouter } from './wizard-template'
|
||||
import { dashboardRouter } from './dashboard'
|
||||
// Round redesign Phase 2 routers
|
||||
import { pipelineRouter } from './pipeline'
|
||||
import { stageRouter } from './stage'
|
||||
import { routingRouter } from './routing'
|
||||
import { stageFilteringRouter } from './stageFiltering'
|
||||
import { stageAssignmentRouter } from './stageAssignment'
|
||||
import { cohortRouter } from './cohort'
|
||||
import { liveRouter } from './live'
|
||||
import { decisionRouter } from './decision'
|
||||
import { awardRouter } from './award'
|
||||
|
||||
/**
|
||||
* Root tRPC router that combines all domain routers
|
||||
*/
|
||||
export const appRouter = router({
|
||||
program: programRouter,
|
||||
project: projectRouter,
|
||||
user: userRouter,
|
||||
assignment: assignmentRouter,
|
||||
evaluation: evaluationRouter,
|
||||
file: fileRouter,
|
||||
export: exportRouter,
|
||||
audit: auditRouter,
|
||||
settings: settingsRouter,
|
||||
gracePeriod: gracePeriodRouter,
|
||||
// Phase 2 routers
|
||||
learningResource: learningResourceRouter,
|
||||
partner: partnerRouter,
|
||||
notionImport: notionImportRouter,
|
||||
typeformImport: typeformImportRouter,
|
||||
// Phase 2B routers
|
||||
tag: tagRouter,
|
||||
applicant: applicantRouter,
|
||||
liveVoting: liveVotingRouter,
|
||||
analytics: analyticsRouter,
|
||||
// Storage routers
|
||||
avatar: avatarRouter,
|
||||
logo: logoRouter,
|
||||
// Applicant system routers
|
||||
application: applicationRouter,
|
||||
mentor: mentorRouter,
|
||||
filtering: filteringRouter,
|
||||
specialAward: specialAwardRouter,
|
||||
notification: notificationRouter,
|
||||
// Feature expansion routers
|
||||
message: messageRouter,
|
||||
webhook: webhookRouter,
|
||||
projectPool: projectPoolRouter,
|
||||
wizardTemplate: wizardTemplateRouter,
|
||||
dashboard: dashboardRouter,
|
||||
// Round redesign Phase 2 routers
|
||||
pipeline: pipelineRouter,
|
||||
stage: stageRouter,
|
||||
routing: routingRouter,
|
||||
stageFiltering: stageFilteringRouter,
|
||||
stageAssignment: stageAssignmentRouter,
|
||||
cohort: cohortRouter,
|
||||
live: liveRouter,
|
||||
decision: decisionRouter,
|
||||
award: awardRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
import { router } from '../trpc'
|
||||
import { programRouter } from './program'
|
||||
import { projectRouter } from './project'
|
||||
import { userRouter } from './user'
|
||||
import { assignmentRouter } from './assignment'
|
||||
import { evaluationRouter } from './evaluation'
|
||||
import { fileRouter } from './file'
|
||||
import { exportRouter } from './export'
|
||||
import { auditRouter } from './audit'
|
||||
import { settingsRouter } from './settings'
|
||||
import { gracePeriodRouter } from './gracePeriod'
|
||||
// Phase 2 routers
|
||||
import { learningResourceRouter } from './learningResource'
|
||||
import { partnerRouter } from './partner'
|
||||
import { notionImportRouter } from './notion-import'
|
||||
import { typeformImportRouter } from './typeform-import'
|
||||
// Phase 2B routers
|
||||
import { tagRouter } from './tag'
|
||||
import { applicantRouter } from './applicant'
|
||||
import { liveVotingRouter } from './live-voting'
|
||||
import { analyticsRouter } from './analytics'
|
||||
// Storage routers
|
||||
import { avatarRouter } from './avatar'
|
||||
import { logoRouter } from './logo'
|
||||
// Applicant system routers
|
||||
import { applicationRouter } from './application'
|
||||
import { mentorRouter } from './mentor'
|
||||
import { filteringRouter } from './filtering'
|
||||
import { specialAwardRouter } from './specialAward'
|
||||
import { notificationRouter } from './notification'
|
||||
// Feature expansion routers
|
||||
import { messageRouter } from './message'
|
||||
import { webhookRouter } from './webhook'
|
||||
import { projectPoolRouter } from './project-pool'
|
||||
import { wizardTemplateRouter } from './wizard-template'
|
||||
import { dashboardRouter } from './dashboard'
|
||||
// Round redesign Phase 2 routers
|
||||
import { pipelineRouter } from './pipeline'
|
||||
import { stageRouter } from './stage'
|
||||
import { routingRouter } from './routing'
|
||||
import { stageFilteringRouter } from './stageFiltering'
|
||||
import { stageAssignmentRouter } from './stageAssignment'
|
||||
import { cohortRouter } from './cohort'
|
||||
import { liveRouter } from './live'
|
||||
import { decisionRouter } from './decision'
|
||||
import { awardRouter } from './award'
|
||||
|
||||
/**
|
||||
* Root tRPC router that combines all domain routers
|
||||
*/
|
||||
export const appRouter = router({
|
||||
program: programRouter,
|
||||
project: projectRouter,
|
||||
user: userRouter,
|
||||
assignment: assignmentRouter,
|
||||
evaluation: evaluationRouter,
|
||||
file: fileRouter,
|
||||
export: exportRouter,
|
||||
audit: auditRouter,
|
||||
settings: settingsRouter,
|
||||
gracePeriod: gracePeriodRouter,
|
||||
// Phase 2 routers
|
||||
learningResource: learningResourceRouter,
|
||||
partner: partnerRouter,
|
||||
notionImport: notionImportRouter,
|
||||
typeformImport: typeformImportRouter,
|
||||
// Phase 2B routers
|
||||
tag: tagRouter,
|
||||
applicant: applicantRouter,
|
||||
liveVoting: liveVotingRouter,
|
||||
analytics: analyticsRouter,
|
||||
// Storage routers
|
||||
avatar: avatarRouter,
|
||||
logo: logoRouter,
|
||||
// Applicant system routers
|
||||
application: applicationRouter,
|
||||
mentor: mentorRouter,
|
||||
filtering: filteringRouter,
|
||||
specialAward: specialAwardRouter,
|
||||
notification: notificationRouter,
|
||||
// Feature expansion routers
|
||||
message: messageRouter,
|
||||
webhook: webhookRouter,
|
||||
projectPool: projectPoolRouter,
|
||||
wizardTemplate: wizardTemplateRouter,
|
||||
dashboard: dashboardRouter,
|
||||
// Round redesign Phase 2 routers
|
||||
pipeline: pipelineRouter,
|
||||
stage: stageRouter,
|
||||
routing: routingRouter,
|
||||
stageFiltering: stageFilteringRouter,
|
||||
stageAssignment: stageAssignmentRouter,
|
||||
cohort: cohortRouter,
|
||||
live: liveRouter,
|
||||
decision: decisionRouter,
|
||||
award: awardRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,339 +1,339 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure, superAdminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const auditRouter = router({
|
||||
/**
|
||||
* List audit logs with filtering and pagination
|
||||
*/
|
||||
list: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
entityType: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { userId, action, entityType, entityId, startDate, endDate, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (userId) where.userId = userId
|
||||
if (action) where.action = action
|
||||
if (entityType) where.entityType = entityType
|
||||
if (entityId) where.entityId = entityId
|
||||
if (startDate || endDate) {
|
||||
where.timestamp = {}
|
||||
if (startDate) (where.timestamp as Record<string, Date>).gte = startDate
|
||||
if (endDate) (where.timestamp as Record<string, Date>).lte = endDate
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
ctx.prisma.auditLog.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: perPage,
|
||||
orderBy: { timestamp: 'desc' },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.auditLog.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific entity
|
||||
*/
|
||||
getByEntity: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.string(),
|
||||
entityId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.auditLog.findMany({
|
||||
where: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific user
|
||||
*/
|
||||
getByUser: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.auditLog.findMany({
|
||||
where: { userId: input.userId },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: input.limit,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get recent activity summary
|
||||
*/
|
||||
getRecentActivity: adminProcedure
|
||||
.input(z.object({ limit: z.number().int().min(1).max(50).default(20) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.auditLog.findMany({
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: input.limit,
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get action statistics
|
||||
*/
|
||||
getStats: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (input.startDate || input.endDate) {
|
||||
where.timestamp = {}
|
||||
if (input.startDate) (where.timestamp as Record<string, Date>).gte = input.startDate
|
||||
if (input.endDate) (where.timestamp as Record<string, Date>).lte = input.endDate
|
||||
}
|
||||
|
||||
const [byAction, byEntity, byUser] = await Promise.all([
|
||||
ctx.prisma.auditLog.groupBy({
|
||||
by: ['action'],
|
||||
where,
|
||||
_count: true,
|
||||
orderBy: { _count: { action: 'desc' } },
|
||||
}),
|
||||
ctx.prisma.auditLog.groupBy({
|
||||
by: ['entityType'],
|
||||
where,
|
||||
_count: true,
|
||||
orderBy: { _count: { entityType: 'desc' } },
|
||||
}),
|
||||
ctx.prisma.auditLog.groupBy({
|
||||
by: ['userId'],
|
||||
where,
|
||||
_count: true,
|
||||
orderBy: { _count: { userId: 'desc' } },
|
||||
take: 10,
|
||||
}),
|
||||
])
|
||||
|
||||
// Get user names for top users
|
||||
const userIds = byUser
|
||||
.map((u) => u.userId)
|
||||
.filter((id): id is string => id !== null)
|
||||
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||
|
||||
return {
|
||||
byAction: byAction.map((a) => ({
|
||||
action: a.action,
|
||||
count: a._count,
|
||||
})),
|
||||
byEntity: byEntity.map((e) => ({
|
||||
entityType: e.entityType,
|
||||
count: e._count,
|
||||
})),
|
||||
byUser: byUser.map((u) => ({
|
||||
userId: u.userId,
|
||||
user: u.userId ? userMap.get(u.userId) : null,
|
||||
count: u._count,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Anomaly Detection & Session Tracking (F14)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Detect anomalous activity patterns within a time window
|
||||
*/
|
||||
getAnomalies: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
timeWindowMinutes: z.number().int().min(1).max(1440).default(60),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Load anomaly rules from settings
|
||||
const rulesSetting = await ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'audit_anomaly_rules' },
|
||||
})
|
||||
|
||||
const rules = rulesSetting?.value
|
||||
? JSON.parse(rulesSetting.value) as {
|
||||
rapid_changes_per_minute?: number
|
||||
bulk_operations_threshold?: number
|
||||
}
|
||||
: { rapid_changes_per_minute: 30, bulk_operations_threshold: 50 }
|
||||
|
||||
const rapidThreshold = rules.rapid_changes_per_minute || 30
|
||||
const bulkThreshold = rules.bulk_operations_threshold || 50
|
||||
|
||||
const windowStart = new Date()
|
||||
windowStart.setMinutes(windowStart.getMinutes() - input.timeWindowMinutes)
|
||||
|
||||
// Get action counts per user in the time window
|
||||
const userActivity = await ctx.prisma.auditLog.groupBy({
|
||||
by: ['userId'],
|
||||
where: {
|
||||
timestamp: { gte: windowStart },
|
||||
userId: { not: null },
|
||||
},
|
||||
_count: true,
|
||||
})
|
||||
|
||||
// Filter for users exceeding thresholds
|
||||
const suspiciousUserIds = userActivity
|
||||
.filter((u) => u._count >= bulkThreshold)
|
||||
.map((u) => u.userId)
|
||||
.filter((id): id is string => id !== null)
|
||||
|
||||
// Get user details
|
||||
const users = suspiciousUserIds.length > 0
|
||||
? await ctx.prisma.user.findMany({
|
||||
where: { id: { in: suspiciousUserIds } },
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
})
|
||||
: []
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||
|
||||
const anomalies = userActivity
|
||||
.filter((u) => u._count >= bulkThreshold)
|
||||
.map((u) => ({
|
||||
userId: u.userId,
|
||||
user: u.userId ? userMap.get(u.userId) || null : null,
|
||||
actionCount: u._count,
|
||||
timeWindowMinutes: input.timeWindowMinutes,
|
||||
actionsPerMinute: u._count / input.timeWindowMinutes,
|
||||
isRapid: (u._count / input.timeWindowMinutes) >= rapidThreshold,
|
||||
isBulk: u._count >= bulkThreshold,
|
||||
}))
|
||||
.sort((a, b) => b.actionCount - a.actionCount)
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
thresholds: {
|
||||
rapidChangesPerMinute: rapidThreshold,
|
||||
bulkOperationsThreshold: bulkThreshold,
|
||||
},
|
||||
timeWindow: {
|
||||
start: windowStart,
|
||||
end: new Date(),
|
||||
minutes: input.timeWindowMinutes,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all audit logs for a specific session
|
||||
*/
|
||||
getSessionTimeline: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const logs = await ctx.prisma.auditLog.findMany({
|
||||
where: { sessionId: input.sessionId },
|
||||
orderBy: { timestamp: 'asc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return logs
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get current audit retention configuration
|
||||
*/
|
||||
getRetentionConfig: adminProcedure.query(async ({ ctx }) => {
|
||||
const setting = await ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'audit_retention_days' },
|
||||
})
|
||||
|
||||
return {
|
||||
retentionDays: setting?.value ? parseInt(setting.value, 10) : 365,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update audit retention configuration (super admin only)
|
||||
*/
|
||||
updateRetentionConfig: superAdminProcedure
|
||||
.input(z.object({ retentionDays: z.number().int().min(30) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const setting = await ctx.prisma.systemSettings.upsert({
|
||||
where: { key: 'audit_retention_days' },
|
||||
update: {
|
||||
value: input.retentionDays.toString(),
|
||||
updatedBy: ctx.user.id,
|
||||
},
|
||||
create: {
|
||||
key: 'audit_retention_days',
|
||||
value: input.retentionDays.toString(),
|
||||
category: 'AUDIT_CONFIG',
|
||||
updatedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_RETENTION_CONFIG',
|
||||
entityType: 'SystemSettings',
|
||||
entityId: setting.id,
|
||||
detailsJson: { retentionDays: input.retentionDays },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
} catch {
|
||||
// Never throw on audit failure
|
||||
}
|
||||
|
||||
return { retentionDays: input.retentionDays }
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure, superAdminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const auditRouter = router({
|
||||
/**
|
||||
* List audit logs with filtering and pagination
|
||||
*/
|
||||
list: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
entityType: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { userId, action, entityType, entityId, startDate, endDate, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (userId) where.userId = userId
|
||||
if (action) where.action = action
|
||||
if (entityType) where.entityType = entityType
|
||||
if (entityId) where.entityId = entityId
|
||||
if (startDate || endDate) {
|
||||
where.timestamp = {}
|
||||
if (startDate) (where.timestamp as Record<string, Date>).gte = startDate
|
||||
if (endDate) (where.timestamp as Record<string, Date>).lte = endDate
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
ctx.prisma.auditLog.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: perPage,
|
||||
orderBy: { timestamp: 'desc' },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.auditLog.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific entity
|
||||
*/
|
||||
getByEntity: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.string(),
|
||||
entityId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.auditLog.findMany({
|
||||
where: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific user
|
||||
*/
|
||||
getByUser: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.auditLog.findMany({
|
||||
where: { userId: input.userId },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: input.limit,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get recent activity summary
|
||||
*/
|
||||
getRecentActivity: adminProcedure
|
||||
.input(z.object({ limit: z.number().int().min(1).max(50).default(20) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.auditLog.findMany({
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: input.limit,
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get action statistics
|
||||
*/
|
||||
getStats: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (input.startDate || input.endDate) {
|
||||
where.timestamp = {}
|
||||
if (input.startDate) (where.timestamp as Record<string, Date>).gte = input.startDate
|
||||
if (input.endDate) (where.timestamp as Record<string, Date>).lte = input.endDate
|
||||
}
|
||||
|
||||
const [byAction, byEntity, byUser] = await Promise.all([
|
||||
ctx.prisma.auditLog.groupBy({
|
||||
by: ['action'],
|
||||
where,
|
||||
_count: true,
|
||||
orderBy: { _count: { action: 'desc' } },
|
||||
}),
|
||||
ctx.prisma.auditLog.groupBy({
|
||||
by: ['entityType'],
|
||||
where,
|
||||
_count: true,
|
||||
orderBy: { _count: { entityType: 'desc' } },
|
||||
}),
|
||||
ctx.prisma.auditLog.groupBy({
|
||||
by: ['userId'],
|
||||
where,
|
||||
_count: true,
|
||||
orderBy: { _count: { userId: 'desc' } },
|
||||
take: 10,
|
||||
}),
|
||||
])
|
||||
|
||||
// Get user names for top users
|
||||
const userIds = byUser
|
||||
.map((u) => u.userId)
|
||||
.filter((id): id is string => id !== null)
|
||||
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||
|
||||
return {
|
||||
byAction: byAction.map((a) => ({
|
||||
action: a.action,
|
||||
count: a._count,
|
||||
})),
|
||||
byEntity: byEntity.map((e) => ({
|
||||
entityType: e.entityType,
|
||||
count: e._count,
|
||||
})),
|
||||
byUser: byUser.map((u) => ({
|
||||
userId: u.userId,
|
||||
user: u.userId ? userMap.get(u.userId) : null,
|
||||
count: u._count,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Anomaly Detection & Session Tracking (F14)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Detect anomalous activity patterns within a time window
|
||||
*/
|
||||
getAnomalies: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
timeWindowMinutes: z.number().int().min(1).max(1440).default(60),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Load anomaly rules from settings
|
||||
const rulesSetting = await ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'audit_anomaly_rules' },
|
||||
})
|
||||
|
||||
const rules = rulesSetting?.value
|
||||
? JSON.parse(rulesSetting.value) as {
|
||||
rapid_changes_per_minute?: number
|
||||
bulk_operations_threshold?: number
|
||||
}
|
||||
: { rapid_changes_per_minute: 30, bulk_operations_threshold: 50 }
|
||||
|
||||
const rapidThreshold = rules.rapid_changes_per_minute || 30
|
||||
const bulkThreshold = rules.bulk_operations_threshold || 50
|
||||
|
||||
const windowStart = new Date()
|
||||
windowStart.setMinutes(windowStart.getMinutes() - input.timeWindowMinutes)
|
||||
|
||||
// Get action counts per user in the time window
|
||||
const userActivity = await ctx.prisma.auditLog.groupBy({
|
||||
by: ['userId'],
|
||||
where: {
|
||||
timestamp: { gte: windowStart },
|
||||
userId: { not: null },
|
||||
},
|
||||
_count: true,
|
||||
})
|
||||
|
||||
// Filter for users exceeding thresholds
|
||||
const suspiciousUserIds = userActivity
|
||||
.filter((u) => u._count >= bulkThreshold)
|
||||
.map((u) => u.userId)
|
||||
.filter((id): id is string => id !== null)
|
||||
|
||||
// Get user details
|
||||
const users = suspiciousUserIds.length > 0
|
||||
? await ctx.prisma.user.findMany({
|
||||
where: { id: { in: suspiciousUserIds } },
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
})
|
||||
: []
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]))
|
||||
|
||||
const anomalies = userActivity
|
||||
.filter((u) => u._count >= bulkThreshold)
|
||||
.map((u) => ({
|
||||
userId: u.userId,
|
||||
user: u.userId ? userMap.get(u.userId) || null : null,
|
||||
actionCount: u._count,
|
||||
timeWindowMinutes: input.timeWindowMinutes,
|
||||
actionsPerMinute: u._count / input.timeWindowMinutes,
|
||||
isRapid: (u._count / input.timeWindowMinutes) >= rapidThreshold,
|
||||
isBulk: u._count >= bulkThreshold,
|
||||
}))
|
||||
.sort((a, b) => b.actionCount - a.actionCount)
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
thresholds: {
|
||||
rapidChangesPerMinute: rapidThreshold,
|
||||
bulkOperationsThreshold: bulkThreshold,
|
||||
},
|
||||
timeWindow: {
|
||||
start: windowStart,
|
||||
end: new Date(),
|
||||
minutes: input.timeWindowMinutes,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all audit logs for a specific session
|
||||
*/
|
||||
getSessionTimeline: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const logs = await ctx.prisma.auditLog.findMany({
|
||||
where: { sessionId: input.sessionId },
|
||||
orderBy: { timestamp: 'asc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return logs
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get current audit retention configuration
|
||||
*/
|
||||
getRetentionConfig: adminProcedure.query(async ({ ctx }) => {
|
||||
const setting = await ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'audit_retention_days' },
|
||||
})
|
||||
|
||||
return {
|
||||
retentionDays: setting?.value ? parseInt(setting.value, 10) : 365,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update audit retention configuration (super admin only)
|
||||
*/
|
||||
updateRetentionConfig: superAdminProcedure
|
||||
.input(z.object({ retentionDays: z.number().int().min(30) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const setting = await ctx.prisma.systemSettings.upsert({
|
||||
where: { key: 'audit_retention_days' },
|
||||
update: {
|
||||
value: input.retentionDays.toString(),
|
||||
updatedBy: ctx.user.id,
|
||||
},
|
||||
create: {
|
||||
key: 'audit_retention_days',
|
||||
value: input.retentionDays.toString(),
|
||||
category: 'AUDIT_CONFIG',
|
||||
updatedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_RETENTION_CONFIG',
|
||||
entityType: 'SystemSettings',
|
||||
entityId: setting.id,
|
||||
detailsJson: { retentionDays: input.retentionDays },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
} catch {
|
||||
// Never throw on audit failure
|
||||
}
|
||||
|
||||
return { retentionDays: input.retentionDays }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure } from '../trpc'
|
||||
import { generateAvatarKey, type StorageProviderType } from '@/lib/storage'
|
||||
import {
|
||||
getImageUploadUrl,
|
||||
confirmImageUpload,
|
||||
getImageUrl,
|
||||
deleteImage,
|
||||
type ImageUploadConfig,
|
||||
} from '../utils/image-upload'
|
||||
|
||||
type AvatarSelect = {
|
||||
profileImageKey: string | null
|
||||
profileImageProvider: string | null
|
||||
}
|
||||
|
||||
const avatarConfig: ImageUploadConfig<AvatarSelect> = {
|
||||
label: 'avatar',
|
||||
generateKey: generateAvatarKey,
|
||||
findCurrent: (prisma, entityId) =>
|
||||
prisma.user.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { profileImageKey: true, profileImageProvider: true },
|
||||
}),
|
||||
getImageKey: (record) => record.profileImageKey,
|
||||
getProviderType: (record) =>
|
||||
(record.profileImageProvider as StorageProviderType) || 's3',
|
||||
setImage: (prisma, entityId, key, providerType) =>
|
||||
prisma.user.update({
|
||||
where: { id: entityId },
|
||||
data: { profileImageKey: key, profileImageProvider: providerType },
|
||||
}),
|
||||
clearImage: (prisma, entityId) =>
|
||||
prisma.user.update({
|
||||
where: { id: entityId },
|
||||
data: { profileImageKey: null, profileImageProvider: null },
|
||||
}),
|
||||
auditEntityType: 'User',
|
||||
auditFieldName: 'profileImageKey',
|
||||
}
|
||||
|
||||
export const avatarRouter = router({
|
||||
/**
|
||||
* Get a pre-signed URL for uploading an avatar
|
||||
*/
|
||||
getUploadUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
fileName: z.string(),
|
||||
contentType: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return getImageUploadUrl(
|
||||
ctx.user.id,
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
generateAvatarKey
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Confirm avatar upload and update user profile
|
||||
*/
|
||||
confirmUpload: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
providerType: z.enum(['s3', 'local']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id
|
||||
|
||||
await confirmImageUpload(ctx.prisma, avatarConfig, userId, input.key, input.providerType, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Return the updated user fields to match original API contract
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
profileImageKey: true,
|
||||
profileImageProvider: true,
|
||||
},
|
||||
})
|
||||
|
||||
return user
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the current user's avatar URL
|
||||
*/
|
||||
getUrl: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getImageUrl(ctx.prisma, avatarConfig, ctx.user.id)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete the current user's avatar
|
||||
*/
|
||||
delete: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
return deleteImage(ctx.prisma, avatarConfig, ctx.user.id, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure } from '../trpc'
|
||||
import { generateAvatarKey, type StorageProviderType } from '@/lib/storage'
|
||||
import {
|
||||
getImageUploadUrl,
|
||||
confirmImageUpload,
|
||||
getImageUrl,
|
||||
deleteImage,
|
||||
type ImageUploadConfig,
|
||||
} from '../utils/image-upload'
|
||||
|
||||
type AvatarSelect = {
|
||||
profileImageKey: string | null
|
||||
profileImageProvider: string | null
|
||||
}
|
||||
|
||||
const avatarConfig: ImageUploadConfig<AvatarSelect> = {
|
||||
label: 'avatar',
|
||||
generateKey: generateAvatarKey,
|
||||
findCurrent: (prisma, entityId) =>
|
||||
prisma.user.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { profileImageKey: true, profileImageProvider: true },
|
||||
}),
|
||||
getImageKey: (record) => record.profileImageKey,
|
||||
getProviderType: (record) =>
|
||||
(record.profileImageProvider as StorageProviderType) || 's3',
|
||||
setImage: (prisma, entityId, key, providerType) =>
|
||||
prisma.user.update({
|
||||
where: { id: entityId },
|
||||
data: { profileImageKey: key, profileImageProvider: providerType },
|
||||
}),
|
||||
clearImage: (prisma, entityId) =>
|
||||
prisma.user.update({
|
||||
where: { id: entityId },
|
||||
data: { profileImageKey: null, profileImageProvider: null },
|
||||
}),
|
||||
auditEntityType: 'User',
|
||||
auditFieldName: 'profileImageKey',
|
||||
}
|
||||
|
||||
export const avatarRouter = router({
|
||||
/**
|
||||
* Get a pre-signed URL for uploading an avatar
|
||||
*/
|
||||
getUploadUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
fileName: z.string(),
|
||||
contentType: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return getImageUploadUrl(
|
||||
ctx.user.id,
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
generateAvatarKey
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Confirm avatar upload and update user profile
|
||||
*/
|
||||
confirmUpload: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
providerType: z.enum(['s3', 'local']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id
|
||||
|
||||
await confirmImageUpload(ctx.prisma, avatarConfig, userId, input.key, input.providerType, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Return the updated user fields to match original API contract
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
profileImageKey: true,
|
||||
profileImageProvider: true,
|
||||
},
|
||||
})
|
||||
|
||||
return user
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the current user's avatar URL
|
||||
*/
|
||||
getUrl: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getImageUrl(ctx.prisma, avatarConfig, ctx.user.id)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete the current user's avatar
|
||||
*/
|
||||
delete: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
return deleteImage(ctx.prisma, avatarConfig, ctx.user.id, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,331 +1,331 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const cohortRouter = router({
|
||||
/**
|
||||
* Create a new cohort within a stage
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
votingMode: z.enum(['simple', 'criteria', 'ranked']).default('simple'),
|
||||
windowOpenAt: z.date().optional(),
|
||||
windowCloseAt: z.date().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify stage exists and is of a type that supports cohorts
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
|
||||
if (stage.stageType !== 'LIVE_FINAL' && stage.stageType !== 'SELECTION') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Cohorts can only be created in LIVE_FINAL or SELECTION stages',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate window dates
|
||||
if (input.windowOpenAt && input.windowCloseAt) {
|
||||
if (input.windowCloseAt <= input.windowOpenAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Window close date must be after open date',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const cohort = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.cohort.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
name: input.name,
|
||||
votingMode: input.votingMode,
|
||||
windowOpenAt: input.windowOpenAt ?? null,
|
||||
windowCloseAt: input.windowCloseAt ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Cohort',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
name: input.name,
|
||||
votingMode: input.votingMode,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return cohort
|
||||
}),
|
||||
|
||||
/**
|
||||
* Assign projects to a cohort
|
||||
*/
|
||||
assignProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
cohortId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(200),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify cohort exists
|
||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
||||
where: { id: input.cohortId },
|
||||
})
|
||||
|
||||
if (cohort.isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Cannot modify projects while voting is open',
|
||||
})
|
||||
}
|
||||
|
||||
// Get current max sortOrder
|
||||
const maxOrder = await ctx.prisma.cohortProject.aggregate({
|
||||
where: { cohortId: input.cohortId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
let nextOrder = (maxOrder._max.sortOrder ?? -1) + 1
|
||||
|
||||
// Create cohort project entries (skip duplicates)
|
||||
const created = await ctx.prisma.cohortProject.createMany({
|
||||
data: input.projectIds.map((projectId) => ({
|
||||
cohortId: input.cohortId,
|
||||
projectId,
|
||||
sortOrder: nextOrder++,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'COHORT_PROJECTS_ASSIGNED',
|
||||
entityType: 'Cohort',
|
||||
entityId: input.cohortId,
|
||||
detailsJson: {
|
||||
projectCount: created.count,
|
||||
requested: input.projectIds.length,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { assigned: created.count, requested: input.projectIds.length }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Open voting for a cohort
|
||||
*/
|
||||
openVoting: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
cohortId: z.string(),
|
||||
durationMinutes: z.number().int().min(1).max(1440).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
||||
where: { id: input.cohortId },
|
||||
include: { _count: { select: { projects: true } } },
|
||||
})
|
||||
|
||||
if (cohort.isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Voting is already open for this cohort',
|
||||
})
|
||||
}
|
||||
|
||||
if (cohort._count.projects === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Cohort must have at least one project before opening voting',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const closeAt = input.durationMinutes
|
||||
? new Date(now.getTime() + input.durationMinutes * 60 * 1000)
|
||||
: cohort.windowCloseAt
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.cohort.update({
|
||||
where: { id: input.cohortId },
|
||||
data: {
|
||||
isOpen: true,
|
||||
windowOpenAt: now,
|
||||
windowCloseAt: closeAt,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'COHORT_VOTING_OPENED',
|
||||
entityType: 'Cohort',
|
||||
entityId: input.cohortId,
|
||||
detailsJson: {
|
||||
openedAt: now.toISOString(),
|
||||
closesAt: closeAt?.toISOString() ?? null,
|
||||
projectCount: cohort._count.projects,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Close voting for a cohort
|
||||
*/
|
||||
closeVoting: adminProcedure
|
||||
.input(z.object({ cohortId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
||||
where: { id: input.cohortId },
|
||||
})
|
||||
|
||||
if (!cohort.isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Voting is not currently open for this cohort',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.cohort.update({
|
||||
where: { id: input.cohortId },
|
||||
data: {
|
||||
isOpen: false,
|
||||
windowCloseAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'COHORT_VOTING_CLOSED',
|
||||
entityType: 'Cohort',
|
||||
entityId: input.cohortId,
|
||||
detailsJson: {
|
||||
closedAt: now.toISOString(),
|
||||
wasOpenSince: cohort.windowOpenAt?.toISOString(),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* List cohorts for a stage
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.cohort.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
_count: { select: { projects: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get cohort with projects and vote summary
|
||||
*/
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
stage: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
stageType: true,
|
||||
track: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pipeline: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
tags: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get vote counts per project in the cohort's stage session
|
||||
const projectIds = cohort.projects.map((p) => p.projectId)
|
||||
const voteSummary =
|
||||
projectIds.length > 0
|
||||
? await ctx.prisma.liveVote.groupBy({
|
||||
by: ['projectId'],
|
||||
where: {
|
||||
projectId: { in: projectIds },
|
||||
session: { stageId: cohort.stage.id },
|
||||
},
|
||||
_count: true,
|
||||
_avg: { score: true },
|
||||
})
|
||||
: []
|
||||
|
||||
const voteMap = new Map(
|
||||
voteSummary.map((v) => [
|
||||
v.projectId,
|
||||
{ voteCount: v._count, avgScore: v._avg?.score ?? 0 },
|
||||
])
|
||||
)
|
||||
|
||||
return {
|
||||
...cohort,
|
||||
projects: cohort.projects.map((cp) => ({
|
||||
...cp,
|
||||
votes: voteMap.get(cp.projectId) ?? { voteCount: 0, avgScore: 0 },
|
||||
})),
|
||||
}
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const cohortRouter = router({
|
||||
/**
|
||||
* Create a new cohort within a stage
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
votingMode: z.enum(['simple', 'criteria', 'ranked']).default('simple'),
|
||||
windowOpenAt: z.date().optional(),
|
||||
windowCloseAt: z.date().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify stage exists and is of a type that supports cohorts
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
|
||||
if (stage.stageType !== 'LIVE_FINAL' && stage.stageType !== 'SELECTION') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Cohorts can only be created in LIVE_FINAL or SELECTION stages',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate window dates
|
||||
if (input.windowOpenAt && input.windowCloseAt) {
|
||||
if (input.windowCloseAt <= input.windowOpenAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Window close date must be after open date',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const cohort = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.cohort.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
name: input.name,
|
||||
votingMode: input.votingMode,
|
||||
windowOpenAt: input.windowOpenAt ?? null,
|
||||
windowCloseAt: input.windowCloseAt ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Cohort',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
name: input.name,
|
||||
votingMode: input.votingMode,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return cohort
|
||||
}),
|
||||
|
||||
/**
|
||||
* Assign projects to a cohort
|
||||
*/
|
||||
assignProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
cohortId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(200),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify cohort exists
|
||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
||||
where: { id: input.cohortId },
|
||||
})
|
||||
|
||||
if (cohort.isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Cannot modify projects while voting is open',
|
||||
})
|
||||
}
|
||||
|
||||
// Get current max sortOrder
|
||||
const maxOrder = await ctx.prisma.cohortProject.aggregate({
|
||||
where: { cohortId: input.cohortId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
let nextOrder = (maxOrder._max.sortOrder ?? -1) + 1
|
||||
|
||||
// Create cohort project entries (skip duplicates)
|
||||
const created = await ctx.prisma.cohortProject.createMany({
|
||||
data: input.projectIds.map((projectId) => ({
|
||||
cohortId: input.cohortId,
|
||||
projectId,
|
||||
sortOrder: nextOrder++,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'COHORT_PROJECTS_ASSIGNED',
|
||||
entityType: 'Cohort',
|
||||
entityId: input.cohortId,
|
||||
detailsJson: {
|
||||
projectCount: created.count,
|
||||
requested: input.projectIds.length,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { assigned: created.count, requested: input.projectIds.length }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Open voting for a cohort
|
||||
*/
|
||||
openVoting: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
cohortId: z.string(),
|
||||
durationMinutes: z.number().int().min(1).max(1440).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
||||
where: { id: input.cohortId },
|
||||
include: { _count: { select: { projects: true } } },
|
||||
})
|
||||
|
||||
if (cohort.isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Voting is already open for this cohort',
|
||||
})
|
||||
}
|
||||
|
||||
if (cohort._count.projects === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Cohort must have at least one project before opening voting',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const closeAt = input.durationMinutes
|
||||
? new Date(now.getTime() + input.durationMinutes * 60 * 1000)
|
||||
: cohort.windowCloseAt
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.cohort.update({
|
||||
where: { id: input.cohortId },
|
||||
data: {
|
||||
isOpen: true,
|
||||
windowOpenAt: now,
|
||||
windowCloseAt: closeAt,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'COHORT_VOTING_OPENED',
|
||||
entityType: 'Cohort',
|
||||
entityId: input.cohortId,
|
||||
detailsJson: {
|
||||
openedAt: now.toISOString(),
|
||||
closesAt: closeAt?.toISOString() ?? null,
|
||||
projectCount: cohort._count.projects,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Close voting for a cohort
|
||||
*/
|
||||
closeVoting: adminProcedure
|
||||
.input(z.object({ cohortId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
||||
where: { id: input.cohortId },
|
||||
})
|
||||
|
||||
if (!cohort.isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Voting is not currently open for this cohort',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.cohort.update({
|
||||
where: { id: input.cohortId },
|
||||
data: {
|
||||
isOpen: false,
|
||||
windowCloseAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'COHORT_VOTING_CLOSED',
|
||||
entityType: 'Cohort',
|
||||
entityId: input.cohortId,
|
||||
detailsJson: {
|
||||
closedAt: now.toISOString(),
|
||||
wasOpenSince: cohort.windowOpenAt?.toISOString(),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* List cohorts for a stage
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.cohort.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
_count: { select: { projects: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get cohort with projects and vote summary
|
||||
*/
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const cohort = await ctx.prisma.cohort.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
stage: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
stageType: true,
|
||||
track: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
pipeline: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
tags: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get vote counts per project in the cohort's stage session
|
||||
const projectIds = cohort.projects.map((p) => p.projectId)
|
||||
const voteSummary =
|
||||
projectIds.length > 0
|
||||
? await ctx.prisma.liveVote.groupBy({
|
||||
by: ['projectId'],
|
||||
where: {
|
||||
projectId: { in: projectIds },
|
||||
session: { stageId: cohort.stage.id },
|
||||
},
|
||||
_count: true,
|
||||
_avg: { score: true },
|
||||
})
|
||||
: []
|
||||
|
||||
const voteMap = new Map(
|
||||
voteSummary.map((v) => [
|
||||
v.projectId,
|
||||
{ voteCount: v._count, avgScore: v._avg?.score ?? 0 },
|
||||
])
|
||||
)
|
||||
|
||||
return {
|
||||
...cohort,
|
||||
projects: cohort.projects.map((cp) => ({
|
||||
...cp,
|
||||
votes: voteMap.get(cp.projectId) ?? { voteCount: 0, avgScore: 0 },
|
||||
})),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,187 +1,187 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
|
||||
export const dashboardRouter = router({
|
||||
/**
|
||||
* Get all dashboard stats in a single query batch.
|
||||
* Replaces the 16 parallel Prisma queries that were previously
|
||||
* run during SSR, which blocked the event loop and caused 503s.
|
||||
*/
|
||||
getStats: adminProcedure
|
||||
.input(z.object({ editionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { editionId } = input
|
||||
|
||||
const edition = await ctx.prisma.program.findUnique({
|
||||
where: { id: editionId },
|
||||
select: { name: true, year: true },
|
||||
})
|
||||
|
||||
if (!edition) return null
|
||||
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [
|
||||
activeStageCount,
|
||||
totalStageCount,
|
||||
projectCount,
|
||||
newProjectsThisWeek,
|
||||
totalJurors,
|
||||
activeJurors,
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
recentStages,
|
||||
latestProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
pendingCOIs,
|
||||
draftStages,
|
||||
unassignedProjects,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.stage.count({
|
||||
where: { track: { pipeline: { programId: editionId } }, status: 'STAGE_ACTIVE' },
|
||||
}),
|
||||
ctx.prisma.stage.count({
|
||||
where: { track: { pipeline: { programId: editionId } } },
|
||||
}),
|
||||
ctx.prisma.project.count({
|
||||
where: { programId: editionId },
|
||||
}),
|
||||
ctx.prisma.project.count({
|
||||
where: {
|
||||
programId: editionId,
|
||||
createdAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||
assignments: { some: { stage: { track: { pipeline: { programId: editionId } } } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
assignments: { some: { stage: { track: { pipeline: { programId: editionId } } } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.evaluation.groupBy({
|
||||
by: ['status'],
|
||||
where: { assignment: { stage: { track: { pipeline: { programId: editionId } } } } },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stage: { track: { pipeline: { programId: editionId } } } },
|
||||
}),
|
||||
ctx.prisma.stage.findMany({
|
||||
where: { track: { pipeline: { programId: editionId } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
stageType: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
projectStageStates: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
select: {
|
||||
evaluation: { select: { status: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.findMany({
|
||||
where: { programId: editionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
country: true,
|
||||
competitionCategory: true,
|
||||
oceanIssue: true,
|
||||
logoKey: true,
|
||||
createdAt: true,
|
||||
submittedAt: true,
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { programId: editionId },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['oceanIssue'],
|
||||
where: { programId: editionId },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.auditLog.findMany({
|
||||
where: {
|
||||
timestamp: { gte: sevenDaysAgo },
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
entityType: true,
|
||||
timestamp: true,
|
||||
user: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.conflictOfInterest.count({
|
||||
where: {
|
||||
hasConflict: true,
|
||||
reviewedAt: null,
|
||||
assignment: { stage: { track: { pipeline: { programId: editionId } } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.stage.count({
|
||||
where: { track: { pipeline: { programId: editionId } }, status: 'STAGE_DRAFT' },
|
||||
}),
|
||||
ctx.prisma.project.count({
|
||||
where: {
|
||||
programId: editionId,
|
||||
projectStageStates: {
|
||||
some: {
|
||||
stage: { status: 'STAGE_ACTIVE' },
|
||||
},
|
||||
},
|
||||
assignments: { none: {} },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
edition,
|
||||
activeStageCount,
|
||||
totalStageCount,
|
||||
projectCount,
|
||||
newProjectsThisWeek,
|
||||
totalJurors,
|
||||
activeJurors,
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
recentStages,
|
||||
latestProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
pendingCOIs,
|
||||
draftStages,
|
||||
unassignedProjects,
|
||||
}
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
|
||||
export const dashboardRouter = router({
|
||||
/**
|
||||
* Get all dashboard stats in a single query batch.
|
||||
* Replaces the 16 parallel Prisma queries that were previously
|
||||
* run during SSR, which blocked the event loop and caused 503s.
|
||||
*/
|
||||
getStats: adminProcedure
|
||||
.input(z.object({ editionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { editionId } = input
|
||||
|
||||
const edition = await ctx.prisma.program.findUnique({
|
||||
where: { id: editionId },
|
||||
select: { name: true, year: true },
|
||||
})
|
||||
|
||||
if (!edition) return null
|
||||
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [
|
||||
activeStageCount,
|
||||
totalStageCount,
|
||||
projectCount,
|
||||
newProjectsThisWeek,
|
||||
totalJurors,
|
||||
activeJurors,
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
recentStages,
|
||||
latestProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
pendingCOIs,
|
||||
draftStages,
|
||||
unassignedProjects,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.stage.count({
|
||||
where: { track: { pipeline: { programId: editionId } }, status: 'STAGE_ACTIVE' },
|
||||
}),
|
||||
ctx.prisma.stage.count({
|
||||
where: { track: { pipeline: { programId: editionId } } },
|
||||
}),
|
||||
ctx.prisma.project.count({
|
||||
where: { programId: editionId },
|
||||
}),
|
||||
ctx.prisma.project.count({
|
||||
where: {
|
||||
programId: editionId,
|
||||
createdAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||
assignments: { some: { stage: { track: { pipeline: { programId: editionId } } } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
assignments: { some: { stage: { track: { pipeline: { programId: editionId } } } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.evaluation.groupBy({
|
||||
by: ['status'],
|
||||
where: { assignment: { stage: { track: { pipeline: { programId: editionId } } } } },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { stage: { track: { pipeline: { programId: editionId } } } },
|
||||
}),
|
||||
ctx.prisma.stage.findMany({
|
||||
where: { track: { pipeline: { programId: editionId } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
stageType: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
projectStageStates: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
select: {
|
||||
evaluation: { select: { status: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.findMany({
|
||||
where: { programId: editionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
country: true,
|
||||
competitionCategory: true,
|
||||
oceanIssue: true,
|
||||
logoKey: true,
|
||||
createdAt: true,
|
||||
submittedAt: true,
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { programId: editionId },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['oceanIssue'],
|
||||
where: { programId: editionId },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.auditLog.findMany({
|
||||
where: {
|
||||
timestamp: { gte: sevenDaysAgo },
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
entityType: true,
|
||||
timestamp: true,
|
||||
user: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.conflictOfInterest.count({
|
||||
where: {
|
||||
hasConflict: true,
|
||||
reviewedAt: null,
|
||||
assignment: { stage: { track: { pipeline: { programId: editionId } } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.stage.count({
|
||||
where: { track: { pipeline: { programId: editionId } }, status: 'STAGE_DRAFT' },
|
||||
}),
|
||||
ctx.prisma.project.count({
|
||||
where: {
|
||||
programId: editionId,
|
||||
projectStageStates: {
|
||||
some: {
|
||||
stage: { status: 'STAGE_ACTIVE' },
|
||||
},
|
||||
},
|
||||
assignments: { none: {} },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
edition,
|
||||
activeStageCount,
|
||||
totalStageCount,
|
||||
projectCount,
|
||||
newProjectsThisWeek,
|
||||
totalJurors,
|
||||
activeJurors,
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
recentStages,
|
||||
latestProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
pendingCOIs,
|
||||
draftStages,
|
||||
unassignedProjects,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,353 +1,353 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma, FilteringOutcome } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const decisionRouter = router({
|
||||
/**
|
||||
* Override a project's stage state or filtering result
|
||||
*/
|
||||
override: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.enum([
|
||||
'ProjectStageState',
|
||||
'FilteringResult',
|
||||
'AwardEligibility',
|
||||
]),
|
||||
entityId: z.string(),
|
||||
newValue: z.record(z.unknown()),
|
||||
reasonCode: z.enum([
|
||||
'DATA_CORRECTION',
|
||||
'POLICY_EXCEPTION',
|
||||
'JURY_CONFLICT',
|
||||
'SPONSOR_DECISION',
|
||||
'ADMIN_DISCRETION',
|
||||
]),
|
||||
reasonText: z.string().max(2000).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
let previousValue: Record<string, unknown> = {}
|
||||
|
||||
// Fetch current value based on entity type
|
||||
switch (input.entityType) {
|
||||
case 'ProjectStageState': {
|
||||
const pss = await ctx.prisma.projectStageState.findUniqueOrThrow({
|
||||
where: { id: input.entityId },
|
||||
})
|
||||
previousValue = {
|
||||
state: pss.state,
|
||||
metadataJson: pss.metadataJson,
|
||||
}
|
||||
|
||||
// Validate the new state
|
||||
const newState = input.newValue.state as string | undefined
|
||||
if (
|
||||
newState &&
|
||||
!['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'ROUTED', 'COMPLETED', 'WITHDRAWN'].includes(newState)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Invalid state: ${newState}`,
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.projectStageState.update({
|
||||
where: { id: input.entityId },
|
||||
data: {
|
||||
state: (newState as Prisma.EnumProjectStageStateValueFieldUpdateOperationsInput['set']) ?? pss.state,
|
||||
metadataJson: {
|
||||
...(pss.metadataJson as Record<string, unknown> ?? {}),
|
||||
lastOverride: {
|
||||
by: ctx.user.id,
|
||||
at: new Date().toISOString(),
|
||||
reason: input.reasonCode,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
previousValue: previousValue as Prisma.InputJsonValue,
|
||||
newValueJson: input.newValue as Prisma.InputJsonValue,
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText ?? null,
|
||||
actorId: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'override.applied',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
previousValue,
|
||||
newValue: input.newValue,
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText,
|
||||
} as Prisma.InputJsonValue,
|
||||
snapshotJson: previousValue as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DECISION_OVERRIDE',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
detailsJson: {
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText,
|
||||
previousState: previousValue.state,
|
||||
newState: input.newValue.state,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'FilteringResult': {
|
||||
const fr = await ctx.prisma.filteringResult.findUniqueOrThrow({
|
||||
where: { id: input.entityId },
|
||||
})
|
||||
previousValue = {
|
||||
outcome: fr.outcome,
|
||||
aiScreeningJson: fr.aiScreeningJson,
|
||||
}
|
||||
|
||||
const newOutcome = input.newValue.outcome as string | undefined
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
if (newOutcome) {
|
||||
await tx.filteringResult.update({
|
||||
where: { id: input.entityId },
|
||||
data: { finalOutcome: newOutcome as FilteringOutcome },
|
||||
})
|
||||
}
|
||||
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
previousValue: previousValue as Prisma.InputJsonValue,
|
||||
newValueJson: input.newValue as Prisma.InputJsonValue,
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText ?? null,
|
||||
actorId: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'override.applied',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
previousValue,
|
||||
newValue: input.newValue,
|
||||
reasonCode: input.reasonCode,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DECISION_OVERRIDE',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
detailsJson: {
|
||||
reasonCode: input.reasonCode,
|
||||
previousOutcome: (previousValue as Record<string, unknown>).outcome,
|
||||
newOutcome,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'AwardEligibility': {
|
||||
const ae = await ctx.prisma.awardEligibility.findUniqueOrThrow({
|
||||
where: { id: input.entityId },
|
||||
})
|
||||
previousValue = {
|
||||
eligible: ae.eligible,
|
||||
method: ae.method,
|
||||
}
|
||||
|
||||
const newEligible = input.newValue.eligible as boolean | undefined
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
if (newEligible !== undefined) {
|
||||
await tx.awardEligibility.update({
|
||||
where: { id: input.entityId },
|
||||
data: {
|
||||
eligible: newEligible,
|
||||
method: 'MANUAL',
|
||||
overriddenBy: ctx.user.id,
|
||||
overriddenAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
previousValue: previousValue as Prisma.InputJsonValue,
|
||||
newValueJson: input.newValue as Prisma.InputJsonValue,
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText ?? null,
|
||||
actorId: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'override.applied',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
previousValue,
|
||||
newValue: input.newValue,
|
||||
reasonCode: input.reasonCode,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DECISION_OVERRIDE',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
detailsJson: {
|
||||
reasonCode: input.reasonCode,
|
||||
previousEligible: previousValue.eligible,
|
||||
newEligible,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, entityType: input.entityType, entityId: input.entityId }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the full decision audit timeline for an entity
|
||||
*/
|
||||
auditTimeline: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.string(),
|
||||
entityId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [decisionLogs, overrideActions] = await Promise.all([
|
||||
ctx.prisma.decisionAuditLog.findMany({
|
||||
where: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
ctx.prisma.overrideAction.findMany({
|
||||
where: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
])
|
||||
|
||||
// Merge and sort by timestamp
|
||||
const timeline = [
|
||||
...decisionLogs.map((dl) => ({
|
||||
type: 'decision' as const,
|
||||
id: dl.id,
|
||||
eventType: dl.eventType,
|
||||
actorId: dl.actorId,
|
||||
details: dl.detailsJson,
|
||||
snapshot: dl.snapshotJson,
|
||||
createdAt: dl.createdAt,
|
||||
})),
|
||||
...overrideActions.map((oa) => ({
|
||||
type: 'override' as const,
|
||||
id: oa.id,
|
||||
eventType: `override.${oa.reasonCode}`,
|
||||
actorId: oa.actorId,
|
||||
details: {
|
||||
previousValue: oa.previousValue,
|
||||
newValue: oa.newValueJson,
|
||||
reasonCode: oa.reasonCode,
|
||||
reasonText: oa.reasonText,
|
||||
},
|
||||
snapshot: null,
|
||||
createdAt: oa.createdAt,
|
||||
})),
|
||||
].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
|
||||
return { entityType: input.entityType, entityId: input.entityId, timeline }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get override actions (paginated, admin only)
|
||||
*/
|
||||
getOverrides: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.string().optional(),
|
||||
reasonCode: z
|
||||
.enum([
|
||||
'DATA_CORRECTION',
|
||||
'POLICY_EXCEPTION',
|
||||
'JURY_CONFLICT',
|
||||
'SPONSOR_DECISION',
|
||||
'ADMIN_DISCRETION',
|
||||
])
|
||||
.optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Prisma.OverrideActionWhereInput = {}
|
||||
if (input.entityType) where.entityType = input.entityType
|
||||
if (input.reasonCode) where.reasonCode = input.reasonCode
|
||||
|
||||
const items = await ctx.prisma.overrideAction.findMany({
|
||||
where,
|
||||
take: input.limit + 1,
|
||||
cursor: input.cursor ? { id: input.cursor } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (items.length > input.limit) {
|
||||
const next = items.pop()
|
||||
nextCursor = next?.id
|
||||
}
|
||||
|
||||
return { items, nextCursor }
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma, FilteringOutcome } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const decisionRouter = router({
|
||||
/**
|
||||
* Override a project's stage state or filtering result
|
||||
*/
|
||||
override: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.enum([
|
||||
'ProjectStageState',
|
||||
'FilteringResult',
|
||||
'AwardEligibility',
|
||||
]),
|
||||
entityId: z.string(),
|
||||
newValue: z.record(z.unknown()),
|
||||
reasonCode: z.enum([
|
||||
'DATA_CORRECTION',
|
||||
'POLICY_EXCEPTION',
|
||||
'JURY_CONFLICT',
|
||||
'SPONSOR_DECISION',
|
||||
'ADMIN_DISCRETION',
|
||||
]),
|
||||
reasonText: z.string().max(2000).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
let previousValue: Record<string, unknown> = {}
|
||||
|
||||
// Fetch current value based on entity type
|
||||
switch (input.entityType) {
|
||||
case 'ProjectStageState': {
|
||||
const pss = await ctx.prisma.projectStageState.findUniqueOrThrow({
|
||||
where: { id: input.entityId },
|
||||
})
|
||||
previousValue = {
|
||||
state: pss.state,
|
||||
metadataJson: pss.metadataJson,
|
||||
}
|
||||
|
||||
// Validate the new state
|
||||
const newState = input.newValue.state as string | undefined
|
||||
if (
|
||||
newState &&
|
||||
!['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'ROUTED', 'COMPLETED', 'WITHDRAWN'].includes(newState)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Invalid state: ${newState}`,
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.projectStageState.update({
|
||||
where: { id: input.entityId },
|
||||
data: {
|
||||
state: (newState as Prisma.EnumProjectStageStateValueFieldUpdateOperationsInput['set']) ?? pss.state,
|
||||
metadataJson: {
|
||||
...(pss.metadataJson as Record<string, unknown> ?? {}),
|
||||
lastOverride: {
|
||||
by: ctx.user.id,
|
||||
at: new Date().toISOString(),
|
||||
reason: input.reasonCode,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
previousValue: previousValue as Prisma.InputJsonValue,
|
||||
newValueJson: input.newValue as Prisma.InputJsonValue,
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText ?? null,
|
||||
actorId: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'override.applied',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
previousValue,
|
||||
newValue: input.newValue,
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText,
|
||||
} as Prisma.InputJsonValue,
|
||||
snapshotJson: previousValue as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DECISION_OVERRIDE',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
detailsJson: {
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText,
|
||||
previousState: previousValue.state,
|
||||
newState: input.newValue.state,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'FilteringResult': {
|
||||
const fr = await ctx.prisma.filteringResult.findUniqueOrThrow({
|
||||
where: { id: input.entityId },
|
||||
})
|
||||
previousValue = {
|
||||
outcome: fr.outcome,
|
||||
aiScreeningJson: fr.aiScreeningJson,
|
||||
}
|
||||
|
||||
const newOutcome = input.newValue.outcome as string | undefined
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
if (newOutcome) {
|
||||
await tx.filteringResult.update({
|
||||
where: { id: input.entityId },
|
||||
data: { finalOutcome: newOutcome as FilteringOutcome },
|
||||
})
|
||||
}
|
||||
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
previousValue: previousValue as Prisma.InputJsonValue,
|
||||
newValueJson: input.newValue as Prisma.InputJsonValue,
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText ?? null,
|
||||
actorId: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'override.applied',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
previousValue,
|
||||
newValue: input.newValue,
|
||||
reasonCode: input.reasonCode,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DECISION_OVERRIDE',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
detailsJson: {
|
||||
reasonCode: input.reasonCode,
|
||||
previousOutcome: (previousValue as Record<string, unknown>).outcome,
|
||||
newOutcome,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'AwardEligibility': {
|
||||
const ae = await ctx.prisma.awardEligibility.findUniqueOrThrow({
|
||||
where: { id: input.entityId },
|
||||
})
|
||||
previousValue = {
|
||||
eligible: ae.eligible,
|
||||
method: ae.method,
|
||||
}
|
||||
|
||||
const newEligible = input.newValue.eligible as boolean | undefined
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
if (newEligible !== undefined) {
|
||||
await tx.awardEligibility.update({
|
||||
where: { id: input.entityId },
|
||||
data: {
|
||||
eligible: newEligible,
|
||||
method: 'MANUAL',
|
||||
overriddenBy: ctx.user.id,
|
||||
overriddenAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
previousValue: previousValue as Prisma.InputJsonValue,
|
||||
newValueJson: input.newValue as Prisma.InputJsonValue,
|
||||
reasonCode: input.reasonCode,
|
||||
reasonText: input.reasonText ?? null,
|
||||
actorId: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'override.applied',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: ctx.user.id,
|
||||
detailsJson: {
|
||||
previousValue,
|
||||
newValue: input.newValue,
|
||||
reasonCode: input.reasonCode,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DECISION_OVERRIDE',
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
detailsJson: {
|
||||
reasonCode: input.reasonCode,
|
||||
previousEligible: previousValue.eligible,
|
||||
newEligible,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, entityType: input.entityType, entityId: input.entityId }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the full decision audit timeline for an entity
|
||||
*/
|
||||
auditTimeline: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.string(),
|
||||
entityId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [decisionLogs, overrideActions] = await Promise.all([
|
||||
ctx.prisma.decisionAuditLog.findMany({
|
||||
where: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
ctx.prisma.overrideAction.findMany({
|
||||
where: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
])
|
||||
|
||||
// Merge and sort by timestamp
|
||||
const timeline = [
|
||||
...decisionLogs.map((dl) => ({
|
||||
type: 'decision' as const,
|
||||
id: dl.id,
|
||||
eventType: dl.eventType,
|
||||
actorId: dl.actorId,
|
||||
details: dl.detailsJson,
|
||||
snapshot: dl.snapshotJson,
|
||||
createdAt: dl.createdAt,
|
||||
})),
|
||||
...overrideActions.map((oa) => ({
|
||||
type: 'override' as const,
|
||||
id: oa.id,
|
||||
eventType: `override.${oa.reasonCode}`,
|
||||
actorId: oa.actorId,
|
||||
details: {
|
||||
previousValue: oa.previousValue,
|
||||
newValue: oa.newValueJson,
|
||||
reasonCode: oa.reasonCode,
|
||||
reasonText: oa.reasonText,
|
||||
},
|
||||
snapshot: null,
|
||||
createdAt: oa.createdAt,
|
||||
})),
|
||||
].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
|
||||
return { entityType: input.entityType, entityId: input.entityId, timeline }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get override actions (paginated, admin only)
|
||||
*/
|
||||
getOverrides: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.string().optional(),
|
||||
reasonCode: z
|
||||
.enum([
|
||||
'DATA_CORRECTION',
|
||||
'POLICY_EXCEPTION',
|
||||
'JURY_CONFLICT',
|
||||
'SPONSOR_DECISION',
|
||||
'ADMIN_DISCRETION',
|
||||
])
|
||||
.optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Prisma.OverrideActionWhereInput = {}
|
||||
if (input.entityType) where.entityType = input.entityType
|
||||
if (input.reasonCode) where.reasonCode = input.reasonCode
|
||||
|
||||
const items = await ctx.prisma.overrideAction.findMany({
|
||||
where,
|
||||
take: input.limit + 1,
|
||||
cursor: input.cursor ? { id: input.cursor } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (items.length > input.limit) {
|
||||
const next = items.pop()
|
||||
nextCursor = next?.id
|
||||
}
|
||||
|
||||
return { items, nextCursor }
|
||||
}),
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,205 +1,205 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const gracePeriodRouter = router({
|
||||
/**
|
||||
* Grant a grace period to a juror
|
||||
*/
|
||||
grant: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
userId: z.string(),
|
||||
projectId: z.string().optional(),
|
||||
extendedUntil: z.date(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.create({
|
||||
data: {
|
||||
...input,
|
||||
grantedById: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'GRANT_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: gracePeriod.id,
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
userId: input.userId,
|
||||
projectId: input.projectId,
|
||||
extendedUntil: input.extendedUntil.toISOString(),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return gracePeriod
|
||||
}),
|
||||
|
||||
/**
|
||||
* List grace periods for a stage
|
||||
*/
|
||||
listByStage: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.gracePeriod.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
grantedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List active grace periods for a stage
|
||||
*/
|
||||
listActiveByStage: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.gracePeriod.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
extendedUntil: { gte: new Date() },
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
grantedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { extendedUntil: 'asc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get grace periods for a specific user in a stage
|
||||
*/
|
||||
getByUser: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
userId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.gracePeriod.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
userId: input.userId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a grace period
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
extendedUntil: z.date().optional(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return gracePeriod
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revoke a grace period
|
||||
*/
|
||||
revoke: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REVOKE_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
userId: gracePeriod.userId,
|
||||
stageId: gracePeriod.stageId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return gracePeriod
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk grant grace periods
|
||||
*/
|
||||
bulkGrant: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
userIds: z.array(z.string()),
|
||||
extendedUntil: z.date(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const created = await ctx.prisma.gracePeriod.createMany({
|
||||
data: input.userIds.map((userId) => ({
|
||||
stageId: input.stageId,
|
||||
userId,
|
||||
extendedUntil: input.extendedUntil,
|
||||
reason: input.reason,
|
||||
grantedById: ctx.user.id,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_GRANT_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
userCount: input.userIds.length,
|
||||
created: created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { created: created.count }
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const gracePeriodRouter = router({
|
||||
/**
|
||||
* Grant a grace period to a juror
|
||||
*/
|
||||
grant: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
userId: z.string(),
|
||||
projectId: z.string().optional(),
|
||||
extendedUntil: z.date(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.create({
|
||||
data: {
|
||||
...input,
|
||||
grantedById: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'GRANT_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: gracePeriod.id,
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
userId: input.userId,
|
||||
projectId: input.projectId,
|
||||
extendedUntil: input.extendedUntil.toISOString(),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return gracePeriod
|
||||
}),
|
||||
|
||||
/**
|
||||
* List grace periods for a stage
|
||||
*/
|
||||
listByStage: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.gracePeriod.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
grantedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List active grace periods for a stage
|
||||
*/
|
||||
listActiveByStage: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.gracePeriod.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
extendedUntil: { gte: new Date() },
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
grantedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { extendedUntil: 'asc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get grace periods for a specific user in a stage
|
||||
*/
|
||||
getByUser: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
userId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.gracePeriod.findMany({
|
||||
where: {
|
||||
stageId: input.stageId,
|
||||
userId: input.userId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a grace period
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
extendedUntil: z.date().optional(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return gracePeriod
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revoke a grace period
|
||||
*/
|
||||
revoke: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const gracePeriod = await ctx.prisma.gracePeriod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REVOKE_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
userId: gracePeriod.userId,
|
||||
stageId: gracePeriod.stageId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return gracePeriod
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk grant grace periods
|
||||
*/
|
||||
bulkGrant: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
userIds: z.array(z.string()),
|
||||
extendedUntil: z.date(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const created = await ctx.prisma.gracePeriod.createMany({
|
||||
data: input.userIds.map((userId) => ({
|
||||
stageId: input.stageId,
|
||||
userId,
|
||||
extendedUntil: input.extendedUntil,
|
||||
reason: input.reason,
|
||||
grantedById: ctx.user.id,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_GRANT_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
userCount: input.userIds.length,
|
||||
created: created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { created: created.count }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,133 +1,133 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
|
||||
import {
|
||||
getImageUploadUrl,
|
||||
confirmImageUpload,
|
||||
getImageUrl,
|
||||
deleteImage,
|
||||
type ImageUploadConfig,
|
||||
} from '../utils/image-upload'
|
||||
|
||||
type LogoSelect = {
|
||||
logoKey: string | null
|
||||
logoProvider: string | null
|
||||
}
|
||||
|
||||
const logoConfig: ImageUploadConfig<LogoSelect> = {
|
||||
label: 'logo',
|
||||
generateKey: generateLogoKey,
|
||||
findCurrent: (prisma, entityId) =>
|
||||
prisma.project.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
}),
|
||||
getImageKey: (record) => record.logoKey,
|
||||
getProviderType: (record) =>
|
||||
(record.logoProvider as StorageProviderType) || 's3',
|
||||
setImage: (prisma, entityId, key, providerType) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: key, logoProvider: providerType },
|
||||
}),
|
||||
clearImage: (prisma, entityId) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: null, logoProvider: null },
|
||||
}),
|
||||
auditEntityType: 'Project',
|
||||
auditFieldName: 'logoKey',
|
||||
}
|
||||
|
||||
export const logoRouter = router({
|
||||
/**
|
||||
* Get a pre-signed URL for uploading a project logo
|
||||
*/
|
||||
getUploadUrl: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
fileName: z.string(),
|
||||
contentType: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify project exists
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||
}
|
||||
|
||||
return getImageUploadUrl(
|
||||
input.projectId,
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
generateLogoKey
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Confirm logo upload and update project
|
||||
*/
|
||||
confirmUpload: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
key: z.string(),
|
||||
providerType: z.enum(['s3', 'local']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await confirmImageUpload(
|
||||
ctx.prisma,
|
||||
logoConfig,
|
||||
input.projectId,
|
||||
input.key,
|
||||
input.providerType,
|
||||
{
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}
|
||||
)
|
||||
|
||||
// Return the updated project fields to match original API contract
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
logoKey: true,
|
||||
logoProvider: true,
|
||||
},
|
||||
})
|
||||
|
||||
return project
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a project's logo URL
|
||||
*/
|
||||
getUrl: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a project's logo
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
|
||||
import {
|
||||
getImageUploadUrl,
|
||||
confirmImageUpload,
|
||||
getImageUrl,
|
||||
deleteImage,
|
||||
type ImageUploadConfig,
|
||||
} from '../utils/image-upload'
|
||||
|
||||
type LogoSelect = {
|
||||
logoKey: string | null
|
||||
logoProvider: string | null
|
||||
}
|
||||
|
||||
const logoConfig: ImageUploadConfig<LogoSelect> = {
|
||||
label: 'logo',
|
||||
generateKey: generateLogoKey,
|
||||
findCurrent: (prisma, entityId) =>
|
||||
prisma.project.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
}),
|
||||
getImageKey: (record) => record.logoKey,
|
||||
getProviderType: (record) =>
|
||||
(record.logoProvider as StorageProviderType) || 's3',
|
||||
setImage: (prisma, entityId, key, providerType) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: key, logoProvider: providerType },
|
||||
}),
|
||||
clearImage: (prisma, entityId) =>
|
||||
prisma.project.update({
|
||||
where: { id: entityId },
|
||||
data: { logoKey: null, logoProvider: null },
|
||||
}),
|
||||
auditEntityType: 'Project',
|
||||
auditFieldName: 'logoKey',
|
||||
}
|
||||
|
||||
export const logoRouter = router({
|
||||
/**
|
||||
* Get a pre-signed URL for uploading a project logo
|
||||
*/
|
||||
getUploadUrl: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
fileName: z.string(),
|
||||
contentType: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify project exists
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||
}
|
||||
|
||||
return getImageUploadUrl(
|
||||
input.projectId,
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
generateLogoKey
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Confirm logo upload and update project
|
||||
*/
|
||||
confirmUpload: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
key: z.string(),
|
||||
providerType: z.enum(['s3', 'local']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await confirmImageUpload(
|
||||
ctx.prisma,
|
||||
logoConfig,
|
||||
input.projectId,
|
||||
input.key,
|
||||
input.providerType,
|
||||
{
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}
|
||||
)
|
||||
|
||||
// Return the updated project fields to match original API contract
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
logoKey: true,
|
||||
logoProvider: true,
|
||||
},
|
||||
})
|
||||
|
||||
return project
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a project's logo URL
|
||||
*/
|
||||
getUrl: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a project's logo
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,405 +1,405 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
||||
|
||||
export const messageRouter = router({
|
||||
/**
|
||||
* Send a message to recipients.
|
||||
* Resolves recipient list based on recipientType and delivers via specified channels.
|
||||
*/
|
||||
send: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
recipientType: z.enum(['USER', 'ROLE', 'STAGE_JURY', 'PROGRAM_TEAM', 'ALL']),
|
||||
recipientFilter: z.any().optional(),
|
||||
stageId: z.string().optional(),
|
||||
subject: z.string().min(1).max(500),
|
||||
body: z.string().min(1),
|
||||
deliveryChannels: z.array(z.string()).min(1),
|
||||
scheduledAt: z.string().datetime().optional(),
|
||||
templateId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Resolve recipients based on type
|
||||
const recipientUserIds = await resolveRecipients(
|
||||
ctx.prisma,
|
||||
input.recipientType,
|
||||
input.recipientFilter,
|
||||
input.stageId
|
||||
)
|
||||
|
||||
if (recipientUserIds.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No recipients found for the given criteria',
|
||||
})
|
||||
}
|
||||
|
||||
const isScheduled = !!input.scheduledAt
|
||||
const now = new Date()
|
||||
|
||||
// Create message
|
||||
const message = await ctx.prisma.message.create({
|
||||
data: {
|
||||
senderId: ctx.user.id,
|
||||
recipientType: input.recipientType,
|
||||
recipientFilter: input.recipientFilter ?? undefined,
|
||||
stageId: input.stageId,
|
||||
templateId: input.templateId,
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
deliveryChannels: input.deliveryChannels,
|
||||
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : undefined,
|
||||
sentAt: isScheduled ? undefined : now,
|
||||
recipients: {
|
||||
create: recipientUserIds.flatMap((userId) =>
|
||||
input.deliveryChannels.map((channel) => ({
|
||||
userId,
|
||||
channel,
|
||||
}))
|
||||
),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
},
|
||||
})
|
||||
|
||||
// If not scheduled, deliver immediately for EMAIL channel
|
||||
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: recipientUserIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
user.email,
|
||||
user.name || '',
|
||||
'MESSAGE',
|
||||
{
|
||||
name: user.name || undefined,
|
||||
title: input.subject,
|
||||
message: input.body,
|
||||
linkUrl: `${baseUrl}/messages`,
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[Message] Failed to send email to ${user.email}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'SEND_MESSAGE',
|
||||
entityType: 'Message',
|
||||
entityId: message.id,
|
||||
detailsJson: {
|
||||
recipientType: input.recipientType,
|
||||
recipientCount: recipientUserIds.length,
|
||||
channels: input.deliveryChannels,
|
||||
scheduled: isScheduled,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
...message,
|
||||
recipientCount: recipientUserIds.length,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the current user's inbox (messages sent to them).
|
||||
*/
|
||||
inbox: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(20),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const page = input?.page ?? 1
|
||||
const pageSize = input?.pageSize ?? 20
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
ctx.prisma.messageRecipient.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
include: {
|
||||
message: {
|
||||
include: {
|
||||
sender: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { message: { createdAt: 'desc' } },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
ctx.prisma.messageRecipient.count({
|
||||
where: { userId: ctx.user.id },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark a message as read.
|
||||
*/
|
||||
markRead: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const recipient = await ctx.prisma.messageRecipient.findUnique({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!recipient || recipient.userId !== ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Message not found',
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.prisma.messageRecipient.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
isRead: true,
|
||||
readAt: new Date(),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get unread message count for the current user.
|
||||
*/
|
||||
getUnreadCount: protectedProcedure.query(async ({ ctx }) => {
|
||||
const count = await ctx.prisma.messageRecipient.count({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
isRead: false,
|
||||
},
|
||||
})
|
||||
return { count }
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Template procedures
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List all message templates.
|
||||
*/
|
||||
listTemplates: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
category: z.string().optional(),
|
||||
activeOnly: z.boolean().default(true),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.messageTemplate.findMany({
|
||||
where: {
|
||||
...(input?.category ? { category: input.category } : {}),
|
||||
...(input?.activeOnly !== false ? { isActive: true } : {}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a message template.
|
||||
*/
|
||||
createTemplate: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().min(1).max(100),
|
||||
subject: z.string().min(1).max(500),
|
||||
body: z.string().min(1),
|
||||
variables: z.any().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const template = await ctx.prisma.messageTemplate.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
category: input.category,
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
variables: input.variables ?? undefined,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE_MESSAGE_TEMPLATE',
|
||||
entityType: 'MessageTemplate',
|
||||
entityId: template.id,
|
||||
detailsJson: { name: input.name, category: input.category },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return template
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a message template.
|
||||
*/
|
||||
updateTemplate: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().min(1).max(100).optional(),
|
||||
subject: z.string().min(1).max(500).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
variables: z.any().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const template = await ctx.prisma.messageTemplate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.name !== undefined ? { name: data.name } : {}),
|
||||
...(data.category !== undefined ? { category: data.category } : {}),
|
||||
...(data.subject !== undefined ? { subject: data.subject } : {}),
|
||||
...(data.body !== undefined ? { body: data.body } : {}),
|
||||
...(data.variables !== undefined ? { variables: data.variables } : {}),
|
||||
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_MESSAGE_TEMPLATE',
|
||||
entityType: 'MessageTemplate',
|
||||
entityId: id,
|
||||
detailsJson: { updatedFields: Object.keys(data) },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return template
|
||||
}),
|
||||
|
||||
/**
|
||||
* Soft-delete a message template (set isActive=false).
|
||||
*/
|
||||
deleteTemplate: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const template = await ctx.prisma.messageTemplate.update({
|
||||
where: { id: input.id },
|
||||
data: { isActive: false },
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE_MESSAGE_TEMPLATE',
|
||||
entityType: 'MessageTemplate',
|
||||
entityId: input.id,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return template
|
||||
}),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Helper: Resolve recipient user IDs based on recipientType
|
||||
// =============================================================================
|
||||
|
||||
type PrismaClient = Parameters<Parameters<typeof adminProcedure.mutation>[0]>[0]['ctx']['prisma']
|
||||
|
||||
async function resolveRecipients(
|
||||
prisma: PrismaClient,
|
||||
recipientType: string,
|
||||
recipientFilter: unknown,
|
||||
stageId?: string
|
||||
): Promise<string[]> {
|
||||
const filter = recipientFilter as Record<string, unknown> | undefined
|
||||
|
||||
switch (recipientType) {
|
||||
case 'USER': {
|
||||
const userId = filter?.userId as string
|
||||
if (!userId) return []
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
})
|
||||
return user ? [user.id] : []
|
||||
}
|
||||
|
||||
case 'ROLE': {
|
||||
const role = filter?.role as string
|
||||
if (!role) return []
|
||||
const users = await prisma.user.findMany({
|
||||
where: { role: role as any, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
return users.map((u) => u.id)
|
||||
}
|
||||
|
||||
case 'STAGE_JURY': {
|
||||
const targetStageId = stageId || (filter?.stageId as string)
|
||||
if (!targetStageId) return []
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { stageId: targetStageId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
return assignments.map((a) => a.userId)
|
||||
}
|
||||
|
||||
case 'PROGRAM_TEAM': {
|
||||
const programId = filter?.programId as string
|
||||
if (!programId) return []
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { programId },
|
||||
select: { submittedByUserId: true },
|
||||
})
|
||||
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
case 'ALL': {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
return users.map((u) => u.id)
|
||||
}
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
||||
|
||||
export const messageRouter = router({
|
||||
/**
|
||||
* Send a message to recipients.
|
||||
* Resolves recipient list based on recipientType and delivers via specified channels.
|
||||
*/
|
||||
send: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
recipientType: z.enum(['USER', 'ROLE', 'STAGE_JURY', 'PROGRAM_TEAM', 'ALL']),
|
||||
recipientFilter: z.any().optional(),
|
||||
stageId: z.string().optional(),
|
||||
subject: z.string().min(1).max(500),
|
||||
body: z.string().min(1),
|
||||
deliveryChannels: z.array(z.string()).min(1),
|
||||
scheduledAt: z.string().datetime().optional(),
|
||||
templateId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Resolve recipients based on type
|
||||
const recipientUserIds = await resolveRecipients(
|
||||
ctx.prisma,
|
||||
input.recipientType,
|
||||
input.recipientFilter,
|
||||
input.stageId
|
||||
)
|
||||
|
||||
if (recipientUserIds.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No recipients found for the given criteria',
|
||||
})
|
||||
}
|
||||
|
||||
const isScheduled = !!input.scheduledAt
|
||||
const now = new Date()
|
||||
|
||||
// Create message
|
||||
const message = await ctx.prisma.message.create({
|
||||
data: {
|
||||
senderId: ctx.user.id,
|
||||
recipientType: input.recipientType,
|
||||
recipientFilter: input.recipientFilter ?? undefined,
|
||||
stageId: input.stageId,
|
||||
templateId: input.templateId,
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
deliveryChannels: input.deliveryChannels,
|
||||
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : undefined,
|
||||
sentAt: isScheduled ? undefined : now,
|
||||
recipients: {
|
||||
create: recipientUserIds.flatMap((userId) =>
|
||||
input.deliveryChannels.map((channel) => ({
|
||||
userId,
|
||||
channel,
|
||||
}))
|
||||
),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
},
|
||||
})
|
||||
|
||||
// If not scheduled, deliver immediately for EMAIL channel
|
||||
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: recipientUserIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
user.email,
|
||||
user.name || '',
|
||||
'MESSAGE',
|
||||
{
|
||||
name: user.name || undefined,
|
||||
title: input.subject,
|
||||
message: input.body,
|
||||
linkUrl: `${baseUrl}/messages`,
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[Message] Failed to send email to ${user.email}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'SEND_MESSAGE',
|
||||
entityType: 'Message',
|
||||
entityId: message.id,
|
||||
detailsJson: {
|
||||
recipientType: input.recipientType,
|
||||
recipientCount: recipientUserIds.length,
|
||||
channels: input.deliveryChannels,
|
||||
scheduled: isScheduled,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
...message,
|
||||
recipientCount: recipientUserIds.length,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the current user's inbox (messages sent to them).
|
||||
*/
|
||||
inbox: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(20),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const page = input?.page ?? 1
|
||||
const pageSize = input?.pageSize ?? 20
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
ctx.prisma.messageRecipient.findMany({
|
||||
where: { userId: ctx.user.id },
|
||||
include: {
|
||||
message: {
|
||||
include: {
|
||||
sender: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { message: { createdAt: 'desc' } },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
ctx.prisma.messageRecipient.count({
|
||||
where: { userId: ctx.user.id },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark a message as read.
|
||||
*/
|
||||
markRead: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const recipient = await ctx.prisma.messageRecipient.findUnique({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!recipient || recipient.userId !== ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Message not found',
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.prisma.messageRecipient.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
isRead: true,
|
||||
readAt: new Date(),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get unread message count for the current user.
|
||||
*/
|
||||
getUnreadCount: protectedProcedure.query(async ({ ctx }) => {
|
||||
const count = await ctx.prisma.messageRecipient.count({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
isRead: false,
|
||||
},
|
||||
})
|
||||
return { count }
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// Template procedures
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List all message templates.
|
||||
*/
|
||||
listTemplates: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
category: z.string().optional(),
|
||||
activeOnly: z.boolean().default(true),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.messageTemplate.findMany({
|
||||
where: {
|
||||
...(input?.category ? { category: input.category } : {}),
|
||||
...(input?.activeOnly !== false ? { isActive: true } : {}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a message template.
|
||||
*/
|
||||
createTemplate: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().min(1).max(100),
|
||||
subject: z.string().min(1).max(500),
|
||||
body: z.string().min(1),
|
||||
variables: z.any().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const template = await ctx.prisma.messageTemplate.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
category: input.category,
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
variables: input.variables ?? undefined,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE_MESSAGE_TEMPLATE',
|
||||
entityType: 'MessageTemplate',
|
||||
entityId: template.id,
|
||||
detailsJson: { name: input.name, category: input.category },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return template
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a message template.
|
||||
*/
|
||||
updateTemplate: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().min(1).max(100).optional(),
|
||||
subject: z.string().min(1).max(500).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
variables: z.any().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const template = await ctx.prisma.messageTemplate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.name !== undefined ? { name: data.name } : {}),
|
||||
...(data.category !== undefined ? { category: data.category } : {}),
|
||||
...(data.subject !== undefined ? { subject: data.subject } : {}),
|
||||
...(data.body !== undefined ? { body: data.body } : {}),
|
||||
...(data.variables !== undefined ? { variables: data.variables } : {}),
|
||||
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_MESSAGE_TEMPLATE',
|
||||
entityType: 'MessageTemplate',
|
||||
entityId: id,
|
||||
detailsJson: { updatedFields: Object.keys(data) },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return template
|
||||
}),
|
||||
|
||||
/**
|
||||
* Soft-delete a message template (set isActive=false).
|
||||
*/
|
||||
deleteTemplate: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const template = await ctx.prisma.messageTemplate.update({
|
||||
where: { id: input.id },
|
||||
data: { isActive: false },
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE_MESSAGE_TEMPLATE',
|
||||
entityType: 'MessageTemplate',
|
||||
entityId: input.id,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return template
|
||||
}),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Helper: Resolve recipient user IDs based on recipientType
|
||||
// =============================================================================
|
||||
|
||||
type PrismaClient = Parameters<Parameters<typeof adminProcedure.mutation>[0]>[0]['ctx']['prisma']
|
||||
|
||||
async function resolveRecipients(
|
||||
prisma: PrismaClient,
|
||||
recipientType: string,
|
||||
recipientFilter: unknown,
|
||||
stageId?: string
|
||||
): Promise<string[]> {
|
||||
const filter = recipientFilter as Record<string, unknown> | undefined
|
||||
|
||||
switch (recipientType) {
|
||||
case 'USER': {
|
||||
const userId = filter?.userId as string
|
||||
if (!userId) return []
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
})
|
||||
return user ? [user.id] : []
|
||||
}
|
||||
|
||||
case 'ROLE': {
|
||||
const role = filter?.role as string
|
||||
if (!role) return []
|
||||
const users = await prisma.user.findMany({
|
||||
where: { role: role as any, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
return users.map((u) => u.id)
|
||||
}
|
||||
|
||||
case 'STAGE_JURY': {
|
||||
const targetStageId = stageId || (filter?.stageId as string)
|
||||
if (!targetStageId) return []
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { stageId: targetStageId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
return assignments.map((a) => a.userId)
|
||||
}
|
||||
|
||||
case 'PROGRAM_TEAM': {
|
||||
const programId = filter?.programId as string
|
||||
if (!programId) return []
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { programId },
|
||||
select: { submittedByUserId: true },
|
||||
})
|
||||
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
case 'ALL': {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
return users.map((u) => u.id)
|
||||
}
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,381 +1,381 @@
|
||||
/**
|
||||
* Notification Router
|
||||
*
|
||||
* Handles in-app notification CRUD operations for users.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import {
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadCount,
|
||||
deleteExpiredNotifications,
|
||||
deleteOldNotifications,
|
||||
NotificationIcons,
|
||||
NotificationPriorities,
|
||||
} from '../services/in-app-notification'
|
||||
import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email'
|
||||
|
||||
export const notificationRouter = router({
|
||||
/**
|
||||
* List notifications for the current user
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
unreadOnly: z.boolean().default(false),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
cursor: z.string().optional(), // For infinite scroll pagination
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { unreadOnly, limit, cursor } = input
|
||||
const userId = ctx.user.id
|
||||
|
||||
const where = {
|
||||
userId,
|
||||
...(unreadOnly && { isRead: false }),
|
||||
// Don't show expired notifications
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
}
|
||||
|
||||
const notifications = await ctx.prisma.inAppNotification.findMany({
|
||||
where,
|
||||
take: limit + 1, // Fetch one extra to check if there are more
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(cursor && {
|
||||
cursor: { id: cursor },
|
||||
skip: 1, // Skip the cursor item
|
||||
}),
|
||||
})
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (notifications.length > limit) {
|
||||
const nextItem = notifications.pop()
|
||||
nextCursor = nextItem?.id
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
nextCursor,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get unread notification count for the current user
|
||||
*/
|
||||
getUnreadCount: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getUnreadCount(ctx.user.id)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if there are any urgent unread notifications
|
||||
*/
|
||||
hasUrgent: protectedProcedure.query(async ({ ctx }) => {
|
||||
const count = await ctx.prisma.inAppNotification.count({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
isRead: false,
|
||||
priority: 'urgent',
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
})
|
||||
return count > 0
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
markAsRead: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await markNotificationAsRead(input.id, ctx.user.id)
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark multiple notifications as read by IDs
|
||||
*/
|
||||
markBatchAsRead: protectedProcedure
|
||||
.input(z.object({ ids: z.array(z.string()).min(1).max(50) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.inAppNotification.updateMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
userId: ctx.user.id,
|
||||
isRead: false,
|
||||
},
|
||||
data: { isRead: true, readAt: new Date() },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for the current user
|
||||
*/
|
||||
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await markAllNotificationsAsRead(ctx.user.id)
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a notification (user can only delete their own)
|
||||
*/
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.inAppNotification.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
userId: ctx.user.id, // Ensure user can only delete their own
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get notification email settings (admin only)
|
||||
*/
|
||||
getEmailSettings: adminProcedure.query(async ({ ctx }) => {
|
||||
return ctx.prisma.notificationEmailSetting.findMany({
|
||||
orderBy: [{ category: 'asc' }, { label: 'asc' }],
|
||||
include: {
|
||||
updatedBy: { select: { name: true, email: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a notification email setting (admin only)
|
||||
*/
|
||||
updateEmailSetting: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
notificationType: z.string(),
|
||||
sendEmail: z.boolean(),
|
||||
emailSubject: z.string().optional(),
|
||||
emailTemplate: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { notificationType, sendEmail, emailSubject, emailTemplate } = input
|
||||
|
||||
return ctx.prisma.notificationEmailSetting.upsert({
|
||||
where: { notificationType },
|
||||
update: {
|
||||
sendEmail,
|
||||
emailSubject,
|
||||
emailTemplate,
|
||||
updatedById: ctx.user.id,
|
||||
},
|
||||
create: {
|
||||
notificationType,
|
||||
category: 'custom',
|
||||
label: notificationType,
|
||||
sendEmail,
|
||||
emailSubject,
|
||||
emailTemplate,
|
||||
updatedById: ctx.user.id,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete expired notifications (admin cleanup)
|
||||
*/
|
||||
deleteExpired: adminProcedure.mutation(async () => {
|
||||
const count = await deleteExpiredNotifications()
|
||||
return { deletedCount: count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete old read notifications (admin cleanup)
|
||||
*/
|
||||
deleteOld: adminProcedure
|
||||
.input(z.object({ olderThanDays: z.number().int().min(1).max(365).default(30) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const count = await deleteOldNotifications(input.olderThanDays)
|
||||
return { deletedCount: count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get notification icon and priority mappings (for UI)
|
||||
*/
|
||||
getMappings: protectedProcedure.query(() => {
|
||||
return {
|
||||
icons: NotificationIcons,
|
||||
priorities: NotificationPriorities,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin: Get notification statistics
|
||||
*/
|
||||
getStats: adminProcedure.query(async ({ ctx }) => {
|
||||
const [total, unread, byType, byPriority] = await Promise.all([
|
||||
ctx.prisma.inAppNotification.count(),
|
||||
ctx.prisma.inAppNotification.count({ where: { isRead: false } }),
|
||||
ctx.prisma.inAppNotification.groupBy({
|
||||
by: ['type'],
|
||||
_count: true,
|
||||
orderBy: { _count: { type: 'desc' } },
|
||||
take: 10,
|
||||
}),
|
||||
ctx.prisma.inAppNotification.groupBy({
|
||||
by: ['priority'],
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
total,
|
||||
unread,
|
||||
readRate: total > 0 ? ((total - unread) / total) * 100 : 0,
|
||||
byType: byType.map((t) => ({ type: t.type, count: t._count })),
|
||||
byPriority: byPriority.map((p) => ({ priority: p.priority, count: p._count })),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send a test notification email to the current admin
|
||||
*/
|
||||
sendTestEmail: adminProcedure
|
||||
.input(z.object({ notificationType: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { notificationType } = input
|
||||
|
||||
// Check if this notification type has a styled template
|
||||
const hasStyledTemplate = notificationType in NOTIFICATION_EMAIL_TEMPLATES
|
||||
|
||||
// Get setting for label
|
||||
const setting = await ctx.prisma.notificationEmailSetting.findUnique({
|
||||
where: { notificationType },
|
||||
})
|
||||
|
||||
// Sample data for test emails based on category
|
||||
const sampleData: Record<string, Record<string, unknown>> = {
|
||||
// Team notifications
|
||||
ADVANCED_SEMIFINAL: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
programName: 'Monaco Ocean Protection Challenge 2026',
|
||||
nextSteps: 'Prepare your presentation for the semi-final round.',
|
||||
},
|
||||
ADVANCED_FINAL: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
programName: 'Monaco Ocean Protection Challenge 2026',
|
||||
nextSteps: 'Get ready for the final presentation in Monaco.',
|
||||
},
|
||||
MENTOR_ASSIGNED: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
mentorName: 'Dr. Marine Expert',
|
||||
mentorBio: 'Expert in marine conservation with 20 years of experience.',
|
||||
},
|
||||
NOT_SELECTED: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
roundName: 'Semi-Final Round',
|
||||
},
|
||||
WINNER_ANNOUNCEMENT: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
awardName: 'Grand Prize',
|
||||
prizeDetails: '€50,000 and mentorship program',
|
||||
},
|
||||
|
||||
// Jury notifications
|
||||
ASSIGNED_TO_PROJECT: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
roundName: 'Semi-Final Round',
|
||||
deadline: 'Friday, March 15, 2026',
|
||||
},
|
||||
BATCH_ASSIGNED: {
|
||||
projectCount: 5,
|
||||
roundName: 'Semi-Final Round',
|
||||
deadline: 'Friday, March 15, 2026',
|
||||
},
|
||||
ROUND_NOW_OPEN: {
|
||||
roundName: 'Semi-Final Round',
|
||||
projectCount: 12,
|
||||
deadline: 'Friday, March 15, 2026',
|
||||
},
|
||||
REMINDER_24H: {
|
||||
pendingCount: 3,
|
||||
roundName: 'Semi-Final Round',
|
||||
deadline: 'Tomorrow at 5:00 PM',
|
||||
},
|
||||
REMINDER_1H: {
|
||||
pendingCount: 2,
|
||||
roundName: 'Semi-Final Round',
|
||||
deadline: 'Today at 5:00 PM',
|
||||
},
|
||||
AWARD_VOTING_OPEN: {
|
||||
awardName: 'Innovation Award',
|
||||
finalistCount: 6,
|
||||
deadline: 'Friday, March 15, 2026',
|
||||
},
|
||||
|
||||
// Mentor notifications
|
||||
MENTEE_ASSIGNED: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
teamLeadName: 'John Smith',
|
||||
teamLeadEmail: 'john@example.com',
|
||||
},
|
||||
MENTEE_ADVANCED: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
roundName: 'Semi-Final Round',
|
||||
nextRoundName: 'Final Round',
|
||||
},
|
||||
MENTEE_WON: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
awardName: 'Innovation Award',
|
||||
},
|
||||
|
||||
// Admin notifications
|
||||
NEW_APPLICATION: {
|
||||
projectName: 'New Ocean Project',
|
||||
applicantName: 'Jane Doe',
|
||||
applicantEmail: 'jane@example.com',
|
||||
programName: 'Monaco Ocean Protection Challenge 2026',
|
||||
},
|
||||
FILTERING_COMPLETE: {
|
||||
roundName: 'Initial Review',
|
||||
passedCount: 45,
|
||||
flaggedCount: 12,
|
||||
filteredCount: 8,
|
||||
},
|
||||
FILTERING_FAILED: {
|
||||
roundName: 'Initial Review',
|
||||
error: 'Connection timeout',
|
||||
},
|
||||
}
|
||||
|
||||
const metadata = sampleData[notificationType] || {}
|
||||
const label = setting?.label || notificationType
|
||||
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
ctx.user.email,
|
||||
ctx.user.name || 'Admin',
|
||||
notificationType,
|
||||
{
|
||||
title: `[TEST] ${label}`,
|
||||
message: `This is a test email for the "${label}" notification type.`,
|
||||
linkUrl: `${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/admin/settings`,
|
||||
linkLabel: 'Back to Settings',
|
||||
metadata,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Test email sent to ${ctx.user.email}`,
|
||||
hasStyledTemplate,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to send test email',
|
||||
hasStyledTemplate,
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
/**
|
||||
* Notification Router
|
||||
*
|
||||
* Handles in-app notification CRUD operations for users.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import {
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadCount,
|
||||
deleteExpiredNotifications,
|
||||
deleteOldNotifications,
|
||||
NotificationIcons,
|
||||
NotificationPriorities,
|
||||
} from '../services/in-app-notification'
|
||||
import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email'
|
||||
|
||||
export const notificationRouter = router({
|
||||
/**
|
||||
* List notifications for the current user
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
unreadOnly: z.boolean().default(false),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
cursor: z.string().optional(), // For infinite scroll pagination
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { unreadOnly, limit, cursor } = input
|
||||
const userId = ctx.user.id
|
||||
|
||||
const where = {
|
||||
userId,
|
||||
...(unreadOnly && { isRead: false }),
|
||||
// Don't show expired notifications
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
}
|
||||
|
||||
const notifications = await ctx.prisma.inAppNotification.findMany({
|
||||
where,
|
||||
take: limit + 1, // Fetch one extra to check if there are more
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(cursor && {
|
||||
cursor: { id: cursor },
|
||||
skip: 1, // Skip the cursor item
|
||||
}),
|
||||
})
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (notifications.length > limit) {
|
||||
const nextItem = notifications.pop()
|
||||
nextCursor = nextItem?.id
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
nextCursor,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get unread notification count for the current user
|
||||
*/
|
||||
getUnreadCount: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getUnreadCount(ctx.user.id)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if there are any urgent unread notifications
|
||||
*/
|
||||
hasUrgent: protectedProcedure.query(async ({ ctx }) => {
|
||||
const count = await ctx.prisma.inAppNotification.count({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
isRead: false,
|
||||
priority: 'urgent',
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
})
|
||||
return count > 0
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
markAsRead: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await markNotificationAsRead(input.id, ctx.user.id)
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark multiple notifications as read by IDs
|
||||
*/
|
||||
markBatchAsRead: protectedProcedure
|
||||
.input(z.object({ ids: z.array(z.string()).min(1).max(50) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.inAppNotification.updateMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
userId: ctx.user.id,
|
||||
isRead: false,
|
||||
},
|
||||
data: { isRead: true, readAt: new Date() },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for the current user
|
||||
*/
|
||||
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await markAllNotificationsAsRead(ctx.user.id)
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a notification (user can only delete their own)
|
||||
*/
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.inAppNotification.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
userId: ctx.user.id, // Ensure user can only delete their own
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get notification email settings (admin only)
|
||||
*/
|
||||
getEmailSettings: adminProcedure.query(async ({ ctx }) => {
|
||||
return ctx.prisma.notificationEmailSetting.findMany({
|
||||
orderBy: [{ category: 'asc' }, { label: 'asc' }],
|
||||
include: {
|
||||
updatedBy: { select: { name: true, email: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a notification email setting (admin only)
|
||||
*/
|
||||
updateEmailSetting: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
notificationType: z.string(),
|
||||
sendEmail: z.boolean(),
|
||||
emailSubject: z.string().optional(),
|
||||
emailTemplate: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { notificationType, sendEmail, emailSubject, emailTemplate } = input
|
||||
|
||||
return ctx.prisma.notificationEmailSetting.upsert({
|
||||
where: { notificationType },
|
||||
update: {
|
||||
sendEmail,
|
||||
emailSubject,
|
||||
emailTemplate,
|
||||
updatedById: ctx.user.id,
|
||||
},
|
||||
create: {
|
||||
notificationType,
|
||||
category: 'custom',
|
||||
label: notificationType,
|
||||
sendEmail,
|
||||
emailSubject,
|
||||
emailTemplate,
|
||||
updatedById: ctx.user.id,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete expired notifications (admin cleanup)
|
||||
*/
|
||||
deleteExpired: adminProcedure.mutation(async () => {
|
||||
const count = await deleteExpiredNotifications()
|
||||
return { deletedCount: count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete old read notifications (admin cleanup)
|
||||
*/
|
||||
deleteOld: adminProcedure
|
||||
.input(z.object({ olderThanDays: z.number().int().min(1).max(365).default(30) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const count = await deleteOldNotifications(input.olderThanDays)
|
||||
return { deletedCount: count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get notification icon and priority mappings (for UI)
|
||||
*/
|
||||
getMappings: protectedProcedure.query(() => {
|
||||
return {
|
||||
icons: NotificationIcons,
|
||||
priorities: NotificationPriorities,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Admin: Get notification statistics
|
||||
*/
|
||||
getStats: adminProcedure.query(async ({ ctx }) => {
|
||||
const [total, unread, byType, byPriority] = await Promise.all([
|
||||
ctx.prisma.inAppNotification.count(),
|
||||
ctx.prisma.inAppNotification.count({ where: { isRead: false } }),
|
||||
ctx.prisma.inAppNotification.groupBy({
|
||||
by: ['type'],
|
||||
_count: true,
|
||||
orderBy: { _count: { type: 'desc' } },
|
||||
take: 10,
|
||||
}),
|
||||
ctx.prisma.inAppNotification.groupBy({
|
||||
by: ['priority'],
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
total,
|
||||
unread,
|
||||
readRate: total > 0 ? ((total - unread) / total) * 100 : 0,
|
||||
byType: byType.map((t) => ({ type: t.type, count: t._count })),
|
||||
byPriority: byPriority.map((p) => ({ priority: p.priority, count: p._count })),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send a test notification email to the current admin
|
||||
*/
|
||||
sendTestEmail: adminProcedure
|
||||
.input(z.object({ notificationType: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { notificationType } = input
|
||||
|
||||
// Check if this notification type has a styled template
|
||||
const hasStyledTemplate = notificationType in NOTIFICATION_EMAIL_TEMPLATES
|
||||
|
||||
// Get setting for label
|
||||
const setting = await ctx.prisma.notificationEmailSetting.findUnique({
|
||||
where: { notificationType },
|
||||
})
|
||||
|
||||
// Sample data for test emails based on category
|
||||
const sampleData: Record<string, Record<string, unknown>> = {
|
||||
// Team notifications
|
||||
ADVANCED_SEMIFINAL: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
programName: 'Monaco Ocean Protection Challenge 2026',
|
||||
nextSteps: 'Prepare your presentation for the semi-final round.',
|
||||
},
|
||||
ADVANCED_FINAL: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
programName: 'Monaco Ocean Protection Challenge 2026',
|
||||
nextSteps: 'Get ready for the final presentation in Monaco.',
|
||||
},
|
||||
MENTOR_ASSIGNED: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
mentorName: 'Dr. Marine Expert',
|
||||
mentorBio: 'Expert in marine conservation with 20 years of experience.',
|
||||
},
|
||||
NOT_SELECTED: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
roundName: 'Semi-Final Round',
|
||||
},
|
||||
WINNER_ANNOUNCEMENT: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
awardName: 'Grand Prize',
|
||||
prizeDetails: '€50,000 and mentorship program',
|
||||
},
|
||||
|
||||
// Jury notifications
|
||||
ASSIGNED_TO_PROJECT: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
roundName: 'Semi-Final Round',
|
||||
deadline: 'Friday, March 15, 2026',
|
||||
},
|
||||
BATCH_ASSIGNED: {
|
||||
projectCount: 5,
|
||||
roundName: 'Semi-Final Round',
|
||||
deadline: 'Friday, March 15, 2026',
|
||||
},
|
||||
ROUND_NOW_OPEN: {
|
||||
roundName: 'Semi-Final Round',
|
||||
projectCount: 12,
|
||||
deadline: 'Friday, March 15, 2026',
|
||||
},
|
||||
REMINDER_24H: {
|
||||
pendingCount: 3,
|
||||
roundName: 'Semi-Final Round',
|
||||
deadline: 'Tomorrow at 5:00 PM',
|
||||
},
|
||||
REMINDER_1H: {
|
||||
pendingCount: 2,
|
||||
roundName: 'Semi-Final Round',
|
||||
deadline: 'Today at 5:00 PM',
|
||||
},
|
||||
AWARD_VOTING_OPEN: {
|
||||
awardName: 'Innovation Award',
|
||||
finalistCount: 6,
|
||||
deadline: 'Friday, March 15, 2026',
|
||||
},
|
||||
|
||||
// Mentor notifications
|
||||
MENTEE_ASSIGNED: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
teamLeadName: 'John Smith',
|
||||
teamLeadEmail: 'john@example.com',
|
||||
},
|
||||
MENTEE_ADVANCED: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
roundName: 'Semi-Final Round',
|
||||
nextRoundName: 'Final Round',
|
||||
},
|
||||
MENTEE_WON: {
|
||||
projectName: 'Ocean Cleanup Initiative',
|
||||
awardName: 'Innovation Award',
|
||||
},
|
||||
|
||||
// Admin notifications
|
||||
NEW_APPLICATION: {
|
||||
projectName: 'New Ocean Project',
|
||||
applicantName: 'Jane Doe',
|
||||
applicantEmail: 'jane@example.com',
|
||||
programName: 'Monaco Ocean Protection Challenge 2026',
|
||||
},
|
||||
FILTERING_COMPLETE: {
|
||||
roundName: 'Initial Review',
|
||||
passedCount: 45,
|
||||
flaggedCount: 12,
|
||||
filteredCount: 8,
|
||||
},
|
||||
FILTERING_FAILED: {
|
||||
roundName: 'Initial Review',
|
||||
error: 'Connection timeout',
|
||||
},
|
||||
}
|
||||
|
||||
const metadata = sampleData[notificationType] || {}
|
||||
const label = setting?.label || notificationType
|
||||
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
ctx.user.email,
|
||||
ctx.user.name || 'Admin',
|
||||
notificationType,
|
||||
{
|
||||
title: `[TEST] ${label}`,
|
||||
message: `This is a test email for the "${label}" notification type.`,
|
||||
linkUrl: `${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/admin/settings`,
|
||||
linkLabel: 'Back to Settings',
|
||||
metadata,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Test email sent to ${ctx.user.email}`,
|
||||
hasStyledTemplate,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to send test email',
|
||||
hasStyledTemplate,
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,239 +1,239 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import {
|
||||
testNotionConnection,
|
||||
getNotionDatabaseSchema,
|
||||
queryNotionDatabase,
|
||||
} from '@/lib/notion'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
|
||||
export const notionImportRouter = router({
|
||||
/**
|
||||
* Test connection to Notion API
|
||||
*/
|
||||
testConnection: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return testNotionConnection(input.apiKey)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get database schema (properties) for mapping
|
||||
*/
|
||||
getDatabaseSchema: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
databaseId: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
return await getNotionDatabaseSchema(input.apiKey, input.databaseId)
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch database schema',
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Preview data from Notion database
|
||||
*/
|
||||
previewData: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
databaseId: z.string().min(1),
|
||||
limit: z.number().int().min(1).max(10).default(5),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const records = await queryNotionDatabase(
|
||||
input.apiKey,
|
||||
input.databaseId,
|
||||
input.limit
|
||||
)
|
||||
return { records, count: records.length }
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch data from Notion',
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Import projects from Notion database
|
||||
*/
|
||||
importProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
databaseId: z.string().min(1),
|
||||
programId: z.string(),
|
||||
mappings: z.object({
|
||||
title: z.string(),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
}),
|
||||
includeUnmappedInMetadata: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
})
|
||||
|
||||
// Fetch all records from Notion
|
||||
const records = await queryNotionDatabase(input.apiKey, input.databaseId)
|
||||
|
||||
if (records.length === 0) {
|
||||
return { imported: 0, skipped: 0, errors: [] }
|
||||
}
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: [] as Array<{ recordId: string; error: string }>,
|
||||
}
|
||||
|
||||
// Process each record
|
||||
for (const record of records) {
|
||||
try {
|
||||
// Get mapped values
|
||||
const title = getPropertyValue(record.properties, input.mappings.title)
|
||||
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
results.errors.push({
|
||||
recordId: record.id,
|
||||
error: 'Missing or invalid title',
|
||||
})
|
||||
results.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const teamName = input.mappings.teamName
|
||||
? getPropertyValue(record.properties, input.mappings.teamName)
|
||||
: null
|
||||
|
||||
const description = input.mappings.description
|
||||
? getPropertyValue(record.properties, input.mappings.description)
|
||||
: null
|
||||
|
||||
let tags: string[] = []
|
||||
if (input.mappings.tags) {
|
||||
const tagsValue = getPropertyValue(record.properties, input.mappings.tags)
|
||||
if (Array.isArray(tagsValue)) {
|
||||
tags = tagsValue.filter((t): t is string => typeof t === 'string')
|
||||
} else if (typeof tagsValue === 'string') {
|
||||
tags = tagsValue.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Get country and normalize to ISO code
|
||||
let country: string | null = null
|
||||
if (input.mappings.country) {
|
||||
const countryValue = getPropertyValue(record.properties, input.mappings.country)
|
||||
if (typeof countryValue === 'string') {
|
||||
country = normalizeCountryToCode(countryValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Build metadata from unmapped columns
|
||||
let metadataJson: Record<string, unknown> | null = null
|
||||
if (input.includeUnmappedInMetadata) {
|
||||
const mappedKeys = new Set([
|
||||
input.mappings.title,
|
||||
input.mappings.teamName,
|
||||
input.mappings.description,
|
||||
input.mappings.tags,
|
||||
input.mappings.country,
|
||||
].filter(Boolean))
|
||||
|
||||
metadataJson = {}
|
||||
for (const [key, value] of Object.entries(record.properties)) {
|
||||
if (!mappedKeys.has(key) && value !== null && value !== undefined) {
|
||||
metadataJson[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(metadataJson).length === 0) {
|
||||
metadataJson = null
|
||||
}
|
||||
}
|
||||
|
||||
// Create project
|
||||
await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: input.programId,
|
||||
status: 'SUBMITTED',
|
||||
title: title.trim(),
|
||||
teamName: typeof teamName === 'string' ? teamName.trim() : null,
|
||||
description: typeof description === 'string' ? description : null,
|
||||
tags,
|
||||
country,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
externalIdsJson: {
|
||||
notionPageId: record.id,
|
||||
notionDatabaseId: input.databaseId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
results.imported++
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
recordId: record.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
results.skipped++
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
source: 'notion',
|
||||
databaseId: input.databaseId,
|
||||
imported: results.imported,
|
||||
skipped: results.skipped,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return results
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper to get a property value from a record
|
||||
*/
|
||||
function getPropertyValue(
|
||||
properties: Record<string, unknown>,
|
||||
propertyName: string
|
||||
): unknown {
|
||||
return properties[propertyName] ?? null
|
||||
}
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import {
|
||||
testNotionConnection,
|
||||
getNotionDatabaseSchema,
|
||||
queryNotionDatabase,
|
||||
} from '@/lib/notion'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
|
||||
export const notionImportRouter = router({
|
||||
/**
|
||||
* Test connection to Notion API
|
||||
*/
|
||||
testConnection: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return testNotionConnection(input.apiKey)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get database schema (properties) for mapping
|
||||
*/
|
||||
getDatabaseSchema: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
databaseId: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
return await getNotionDatabaseSchema(input.apiKey, input.databaseId)
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch database schema',
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Preview data from Notion database
|
||||
*/
|
||||
previewData: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
databaseId: z.string().min(1),
|
||||
limit: z.number().int().min(1).max(10).default(5),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const records = await queryNotionDatabase(
|
||||
input.apiKey,
|
||||
input.databaseId,
|
||||
input.limit
|
||||
)
|
||||
return { records, count: records.length }
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch data from Notion',
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Import projects from Notion database
|
||||
*/
|
||||
importProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
databaseId: z.string().min(1),
|
||||
programId: z.string(),
|
||||
mappings: z.object({
|
||||
title: z.string(),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
}),
|
||||
includeUnmappedInMetadata: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
})
|
||||
|
||||
// Fetch all records from Notion
|
||||
const records = await queryNotionDatabase(input.apiKey, input.databaseId)
|
||||
|
||||
if (records.length === 0) {
|
||||
return { imported: 0, skipped: 0, errors: [] }
|
||||
}
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: [] as Array<{ recordId: string; error: string }>,
|
||||
}
|
||||
|
||||
// Process each record
|
||||
for (const record of records) {
|
||||
try {
|
||||
// Get mapped values
|
||||
const title = getPropertyValue(record.properties, input.mappings.title)
|
||||
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
results.errors.push({
|
||||
recordId: record.id,
|
||||
error: 'Missing or invalid title',
|
||||
})
|
||||
results.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const teamName = input.mappings.teamName
|
||||
? getPropertyValue(record.properties, input.mappings.teamName)
|
||||
: null
|
||||
|
||||
const description = input.mappings.description
|
||||
? getPropertyValue(record.properties, input.mappings.description)
|
||||
: null
|
||||
|
||||
let tags: string[] = []
|
||||
if (input.mappings.tags) {
|
||||
const tagsValue = getPropertyValue(record.properties, input.mappings.tags)
|
||||
if (Array.isArray(tagsValue)) {
|
||||
tags = tagsValue.filter((t): t is string => typeof t === 'string')
|
||||
} else if (typeof tagsValue === 'string') {
|
||||
tags = tagsValue.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Get country and normalize to ISO code
|
||||
let country: string | null = null
|
||||
if (input.mappings.country) {
|
||||
const countryValue = getPropertyValue(record.properties, input.mappings.country)
|
||||
if (typeof countryValue === 'string') {
|
||||
country = normalizeCountryToCode(countryValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Build metadata from unmapped columns
|
||||
let metadataJson: Record<string, unknown> | null = null
|
||||
if (input.includeUnmappedInMetadata) {
|
||||
const mappedKeys = new Set([
|
||||
input.mappings.title,
|
||||
input.mappings.teamName,
|
||||
input.mappings.description,
|
||||
input.mappings.tags,
|
||||
input.mappings.country,
|
||||
].filter(Boolean))
|
||||
|
||||
metadataJson = {}
|
||||
for (const [key, value] of Object.entries(record.properties)) {
|
||||
if (!mappedKeys.has(key) && value !== null && value !== undefined) {
|
||||
metadataJson[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(metadataJson).length === 0) {
|
||||
metadataJson = null
|
||||
}
|
||||
}
|
||||
|
||||
// Create project
|
||||
await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: input.programId,
|
||||
status: 'SUBMITTED',
|
||||
title: title.trim(),
|
||||
teamName: typeof teamName === 'string' ? teamName.trim() : null,
|
||||
description: typeof description === 'string' ? description : null,
|
||||
tags,
|
||||
country,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
externalIdsJson: {
|
||||
notionPageId: record.id,
|
||||
notionDatabaseId: input.databaseId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
results.imported++
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
recordId: record.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
results.skipped++
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
source: 'notion',
|
||||
databaseId: input.databaseId,
|
||||
imported: results.imported,
|
||||
skipped: results.skipped,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return results
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper to get a property value from a record
|
||||
*/
|
||||
function getPropertyValue(
|
||||
properties: Record<string, unknown>,
|
||||
propertyName: string
|
||||
): unknown {
|
||||
return properties[propertyName] ?? null
|
||||
}
|
||||
|
||||
@@ -1,351 +1,351 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { getPresignedUrl } from '@/lib/minio'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
// Bucket for partner logos
|
||||
export const PARTNER_BUCKET = 'mopc-partners'
|
||||
|
||||
export const partnerRouter = router({
|
||||
/**
|
||||
* List all partners (admin view)
|
||||
*/
|
||||
list: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
|
||||
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).optional(),
|
||||
isActive: 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<string, unknown> = {}
|
||||
|
||||
if (input.programId !== undefined) {
|
||||
where.programId = input.programId
|
||||
}
|
||||
if (input.partnerType) {
|
||||
where.partnerType = input.partnerType
|
||||
}
|
||||
if (input.visibility) {
|
||||
where.visibility = input.visibility
|
||||
}
|
||||
if (input.isActive !== undefined) {
|
||||
where.isActive = input.isActive
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
ctx.prisma.partner.findMany({
|
||||
where,
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
skip: (input.page - 1) * input.perPage,
|
||||
take: input.perPage,
|
||||
}),
|
||||
ctx.prisma.partner.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: input.page,
|
||||
perPage: input.perPage,
|
||||
totalPages: Math.ceil(total / input.perPage),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get partners visible to jury members
|
||||
*/
|
||||
getJuryVisible: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {
|
||||
isActive: true,
|
||||
visibility: { in: ['JURY_VISIBLE', 'PUBLIC'] },
|
||||
}
|
||||
|
||||
if (input.programId) {
|
||||
where.OR = [{ programId: input.programId }, { programId: null }]
|
||||
}
|
||||
if (input.partnerType) {
|
||||
where.partnerType = input.partnerType
|
||||
}
|
||||
|
||||
return ctx.prisma.partner.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get public partners (for public website)
|
||||
*/
|
||||
getPublic: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {
|
||||
isActive: true,
|
||||
visibility: 'PUBLIC',
|
||||
}
|
||||
|
||||
if (input.programId) {
|
||||
where.OR = [{ programId: input.programId }, { programId: null }]
|
||||
}
|
||||
if (input.partnerType) {
|
||||
where.partnerType = input.partnerType
|
||||
}
|
||||
|
||||
return ctx.prisma.partner.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single partner by ID
|
||||
*/
|
||||
get: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.partner.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get logo URL for a partner
|
||||
*/
|
||||
getLogoUrl: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const partner = await ctx.prisma.partner.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!partner.logoBucket || !partner.logoObjectKey) {
|
||||
return { url: null }
|
||||
}
|
||||
|
||||
const url = await getPresignedUrl(partner.logoBucket, partner.logoObjectKey, 'GET', 900)
|
||||
return { url }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new partner (admin only)
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().nullable(),
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
website: z.string().url().optional(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).default('PARTNER'),
|
||||
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).default('ADMIN_ONLY'),
|
||||
sortOrder: z.number().int().default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
// Logo info (set after upload)
|
||||
logoFileName: z.string().optional(),
|
||||
logoBucket: z.string().optional(),
|
||||
logoObjectKey: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const partner = await ctx.prisma.partner.create({
|
||||
data: input,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Partner',
|
||||
entityId: partner.id,
|
||||
detailsJson: { name: input.name, partnerType: input.partnerType },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return partner
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a partner (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
website: z.string().url().optional().nullable(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
|
||||
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
// Logo info
|
||||
logoFileName: z.string().optional().nullable(),
|
||||
logoBucket: z.string().optional().nullable(),
|
||||
logoObjectKey: z.string().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const partner = await ctx.prisma.partner.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Partner',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return partner
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a partner (admin only)
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const partner = await ctx.prisma.partner.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Partner',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: partner.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return partner
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get upload URL for a partner logo (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 = `logos/${timestamp}-${sanitizedName}`
|
||||
|
||||
const url = await getPresignedUrl(PARTNER_BUCKET, objectKey, 'PUT', 3600)
|
||||
|
||||
return {
|
||||
url,
|
||||
bucket: PARTNER_BUCKET,
|
||||
objectKey,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reorder partners (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.partner.update({
|
||||
where: { id: item.id },
|
||||
data: { sortOrder: item.sortOrder },
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REORDER',
|
||||
entityType: 'Partner',
|
||||
detailsJson: { count: input.items.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk update visibility (admin only)
|
||||
*/
|
||||
bulkUpdateVisibility: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.partner.updateMany({
|
||||
where: { id: { in: input.ids } },
|
||||
data: { visibility: input.visibility },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE',
|
||||
entityType: 'Partner',
|
||||
detailsJson: { ids: input.ids, visibility: input.visibility },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { updated: input.ids.length }
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { getPresignedUrl } from '@/lib/minio'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
// Bucket for partner logos
|
||||
export const PARTNER_BUCKET = 'mopc-partners'
|
||||
|
||||
export const partnerRouter = router({
|
||||
/**
|
||||
* List all partners (admin view)
|
||||
*/
|
||||
list: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
|
||||
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).optional(),
|
||||
isActive: 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<string, unknown> = {}
|
||||
|
||||
if (input.programId !== undefined) {
|
||||
where.programId = input.programId
|
||||
}
|
||||
if (input.partnerType) {
|
||||
where.partnerType = input.partnerType
|
||||
}
|
||||
if (input.visibility) {
|
||||
where.visibility = input.visibility
|
||||
}
|
||||
if (input.isActive !== undefined) {
|
||||
where.isActive = input.isActive
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
ctx.prisma.partner.findMany({
|
||||
where,
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
skip: (input.page - 1) * input.perPage,
|
||||
take: input.perPage,
|
||||
}),
|
||||
ctx.prisma.partner.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: input.page,
|
||||
perPage: input.perPage,
|
||||
totalPages: Math.ceil(total / input.perPage),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get partners visible to jury members
|
||||
*/
|
||||
getJuryVisible: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {
|
||||
isActive: true,
|
||||
visibility: { in: ['JURY_VISIBLE', 'PUBLIC'] },
|
||||
}
|
||||
|
||||
if (input.programId) {
|
||||
where.OR = [{ programId: input.programId }, { programId: null }]
|
||||
}
|
||||
if (input.partnerType) {
|
||||
where.partnerType = input.partnerType
|
||||
}
|
||||
|
||||
return ctx.prisma.partner.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get public partners (for public website)
|
||||
*/
|
||||
getPublic: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().optional(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {
|
||||
isActive: true,
|
||||
visibility: 'PUBLIC',
|
||||
}
|
||||
|
||||
if (input.programId) {
|
||||
where.OR = [{ programId: input.programId }, { programId: null }]
|
||||
}
|
||||
if (input.partnerType) {
|
||||
where.partnerType = input.partnerType
|
||||
}
|
||||
|
||||
return ctx.prisma.partner.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single partner by ID
|
||||
*/
|
||||
get: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.partner.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get logo URL for a partner
|
||||
*/
|
||||
getLogoUrl: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const partner = await ctx.prisma.partner.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!partner.logoBucket || !partner.logoObjectKey) {
|
||||
return { url: null }
|
||||
}
|
||||
|
||||
const url = await getPresignedUrl(partner.logoBucket, partner.logoObjectKey, 'GET', 900)
|
||||
return { url }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new partner (admin only)
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string().nullable(),
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
website: z.string().url().optional(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).default('PARTNER'),
|
||||
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).default('ADMIN_ONLY'),
|
||||
sortOrder: z.number().int().default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
// Logo info (set after upload)
|
||||
logoFileName: z.string().optional(),
|
||||
logoBucket: z.string().optional(),
|
||||
logoObjectKey: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const partner = await ctx.prisma.partner.create({
|
||||
data: input,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Partner',
|
||||
entityId: partner.id,
|
||||
detailsJson: { name: input.name, partnerType: input.partnerType },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return partner
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a partner (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
website: z.string().url().optional().nullable(),
|
||||
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
|
||||
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
// Logo info
|
||||
logoFileName: z.string().optional().nullable(),
|
||||
logoBucket: z.string().optional().nullable(),
|
||||
logoObjectKey: z.string().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const partner = await ctx.prisma.partner.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Partner',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return partner
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a partner (admin only)
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const partner = await ctx.prisma.partner.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Partner',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: partner.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return partner
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get upload URL for a partner logo (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 = `logos/${timestamp}-${sanitizedName}`
|
||||
|
||||
const url = await getPresignedUrl(PARTNER_BUCKET, objectKey, 'PUT', 3600)
|
||||
|
||||
return {
|
||||
url,
|
||||
bucket: PARTNER_BUCKET,
|
||||
objectKey,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reorder partners (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.partner.update({
|
||||
where: { id: item.id },
|
||||
data: { sortOrder: item.sortOrder },
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REORDER',
|
||||
entityType: 'Partner',
|
||||
detailsJson: { count: input.items.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk update visibility (admin only)
|
||||
*/
|
||||
bulkUpdateVisibility: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.partner.updateMany({
|
||||
where: { id: { in: input.ids } },
|
||||
data: { visibility: input.visibility },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE',
|
||||
entityType: 'Partner',
|
||||
detailsJson: { ids: input.ids, visibility: input.visibility },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { updated: input.ids.length }
|
||||
}),
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,299 +1,299 @@
|
||||
import { z } from 'zod'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { wizardConfigSchema } from '@/types/wizard-config'
|
||||
import { parseWizardConfig } from '@/lib/wizard-config'
|
||||
|
||||
export const programRouter = router({
|
||||
/**
|
||||
* List all programs with optional filtering.
|
||||
* When includeStages is true, returns stages nested under
|
||||
* pipelines -> tracks -> stages, flattened as `stages` for convenience.
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||
includeStages: z.boolean().optional(),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const includeStages = input?.includeStages || false
|
||||
|
||||
const programs = await ctx.prisma.program.findMany({
|
||||
where: input?.status ? { status: input.status } : undefined,
|
||||
orderBy: { year: 'desc' },
|
||||
include: includeStages
|
||||
? {
|
||||
pipelines: {
|
||||
include: {
|
||||
tracks: {
|
||||
include: {
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { assignments: true, projectStageStates: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
// Flatten stages into a rounds-compatible shape for backward compatibility
|
||||
return programs.map((p) => ({
|
||||
...p,
|
||||
// Provide a flat `stages` array for convenience
|
||||
stages: (p as any).pipelines?.flatMap((pipeline: any) =>
|
||||
pipeline.tracks?.flatMap((track: any) =>
|
||||
(track.stages || []).map((stage: any) => ({
|
||||
...stage,
|
||||
pipelineName: pipeline.name,
|
||||
trackName: track.name,
|
||||
// Backward-compatible _count shape
|
||||
_count: {
|
||||
projects: stage._count?.projectStageStates || 0,
|
||||
assignments: stage._count?.assignments || 0,
|
||||
},
|
||||
}))
|
||||
) || []
|
||||
) || [],
|
||||
// Legacy alias
|
||||
rounds: (p as any).pipelines?.flatMap((pipeline: any) =>
|
||||
pipeline.tracks?.flatMap((track: any) =>
|
||||
(track.stages || []).map((stage: any) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
status: stage.status === 'STAGE_ACTIVE' ? 'ACTIVE'
|
||||
: stage.status === 'STAGE_CLOSED' ? 'CLOSED'
|
||||
: stage.status,
|
||||
votingEndAt: stage.windowCloseAt,
|
||||
_count: {
|
||||
projects: stage._count?.projectStageStates || 0,
|
||||
assignments: stage._count?.assignments || 0,
|
||||
},
|
||||
}))
|
||||
) || []
|
||||
) || [],
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single program with its stages (via pipelines)
|
||||
*/
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
pipelines: {
|
||||
include: {
|
||||
tracks: {
|
||||
include: {
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { assignments: true, projectStageStates: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Flatten stages for convenience
|
||||
const stages = (program as any).pipelines?.flatMap((pipeline: any) =>
|
||||
pipeline.tracks?.flatMap((track: any) =>
|
||||
(track.stages || []).map((stage: any) => ({
|
||||
...stage,
|
||||
_count: {
|
||||
projects: stage._count?.projectStageStates || 0,
|
||||
assignments: stage._count?.assignments || 0,
|
||||
},
|
||||
}))
|
||||
) || []
|
||||
) || []
|
||||
|
||||
return {
|
||||
...program,
|
||||
stages,
|
||||
// Legacy alias
|
||||
rounds: stages.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status === 'STAGE_ACTIVE' ? 'ACTIVE'
|
||||
: s.status === 'STAGE_CLOSED' ? 'CLOSED'
|
||||
: s.status,
|
||||
votingEndAt: s.windowCloseAt,
|
||||
_count: s._count,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new program (admin only)
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
year: z.number().int().min(2020).max(2100),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.create({
|
||||
data: input,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Program',
|
||||
entityId: program.id,
|
||||
detailsJson: input,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return program
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a program (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(100).optional(),
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||
description: z.string().optional(),
|
||||
settingsJson: z.record(z.any()).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const program = await ctx.prisma.program.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Program',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return program
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a program (admin only)
|
||||
* Note: This will cascade delete all rounds, projects, etc.
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Program',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: program.name, year: program.year },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return program
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get wizard config for a program (parsed from settingsJson)
|
||||
*/
|
||||
getWizardConfig: protectedProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
select: { settingsJson: true },
|
||||
})
|
||||
return parseWizardConfig(program.settingsJson)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update wizard config for a program (admin only)
|
||||
*/
|
||||
updateWizardConfig: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
wizardConfig: wizardConfigSchema,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
select: { settingsJson: true },
|
||||
})
|
||||
|
||||
const currentSettings = (program.settingsJson || {}) as Record<string, unknown>
|
||||
|
||||
const updatedSettings = {
|
||||
...currentSettings,
|
||||
wizardConfig: input.wizardConfig,
|
||||
}
|
||||
|
||||
await ctx.prisma.program.update({
|
||||
where: { id: input.programId },
|
||||
data: {
|
||||
settingsJson: updatedSettings as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Program',
|
||||
entityId: input.programId,
|
||||
detailsJson: {
|
||||
field: 'wizardConfig',
|
||||
stepsEnabled: input.wizardConfig.steps.filter((s) => s.enabled).length,
|
||||
totalSteps: input.wizardConfig.steps.length,
|
||||
customFieldsCount: input.wizardConfig.customFields?.length ?? 0,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { wizardConfigSchema } from '@/types/wizard-config'
|
||||
import { parseWizardConfig } from '@/lib/wizard-config'
|
||||
|
||||
export const programRouter = router({
|
||||
/**
|
||||
* List all programs with optional filtering.
|
||||
* When includeStages is true, returns stages nested under
|
||||
* pipelines -> tracks -> stages, flattened as `stages` for convenience.
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||
includeStages: z.boolean().optional(),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const includeStages = input?.includeStages || false
|
||||
|
||||
const programs = await ctx.prisma.program.findMany({
|
||||
where: input?.status ? { status: input.status } : undefined,
|
||||
orderBy: { year: 'desc' },
|
||||
include: includeStages
|
||||
? {
|
||||
pipelines: {
|
||||
include: {
|
||||
tracks: {
|
||||
include: {
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { assignments: true, projectStageStates: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
// Flatten stages into a rounds-compatible shape for backward compatibility
|
||||
return programs.map((p) => ({
|
||||
...p,
|
||||
// Provide a flat `stages` array for convenience
|
||||
stages: (p as any).pipelines?.flatMap((pipeline: any) =>
|
||||
pipeline.tracks?.flatMap((track: any) =>
|
||||
(track.stages || []).map((stage: any) => ({
|
||||
...stage,
|
||||
pipelineName: pipeline.name,
|
||||
trackName: track.name,
|
||||
// Backward-compatible _count shape
|
||||
_count: {
|
||||
projects: stage._count?.projectStageStates || 0,
|
||||
assignments: stage._count?.assignments || 0,
|
||||
},
|
||||
}))
|
||||
) || []
|
||||
) || [],
|
||||
// Legacy alias
|
||||
rounds: (p as any).pipelines?.flatMap((pipeline: any) =>
|
||||
pipeline.tracks?.flatMap((track: any) =>
|
||||
(track.stages || []).map((stage: any) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
status: stage.status === 'STAGE_ACTIVE' ? 'ACTIVE'
|
||||
: stage.status === 'STAGE_CLOSED' ? 'CLOSED'
|
||||
: stage.status,
|
||||
votingEndAt: stage.windowCloseAt,
|
||||
_count: {
|
||||
projects: stage._count?.projectStageStates || 0,
|
||||
assignments: stage._count?.assignments || 0,
|
||||
},
|
||||
}))
|
||||
) || []
|
||||
) || [],
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single program with its stages (via pipelines)
|
||||
*/
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
pipelines: {
|
||||
include: {
|
||||
tracks: {
|
||||
include: {
|
||||
stages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { assignments: true, projectStageStates: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Flatten stages for convenience
|
||||
const stages = (program as any).pipelines?.flatMap((pipeline: any) =>
|
||||
pipeline.tracks?.flatMap((track: any) =>
|
||||
(track.stages || []).map((stage: any) => ({
|
||||
...stage,
|
||||
_count: {
|
||||
projects: stage._count?.projectStageStates || 0,
|
||||
assignments: stage._count?.assignments || 0,
|
||||
},
|
||||
}))
|
||||
) || []
|
||||
) || []
|
||||
|
||||
return {
|
||||
...program,
|
||||
stages,
|
||||
// Legacy alias
|
||||
rounds: stages.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status === 'STAGE_ACTIVE' ? 'ACTIVE'
|
||||
: s.status === 'STAGE_CLOSED' ? 'CLOSED'
|
||||
: s.status,
|
||||
votingEndAt: s.windowCloseAt,
|
||||
_count: s._count,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new program (admin only)
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
year: z.number().int().min(2020).max(2100),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.create({
|
||||
data: input,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Program',
|
||||
entityId: program.id,
|
||||
detailsJson: input,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return program
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a program (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(100).optional(),
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||
description: z.string().optional(),
|
||||
settingsJson: z.record(z.any()).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const program = await ctx.prisma.program.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Program',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return program
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a program (admin only)
|
||||
* Note: This will cascade delete all rounds, projects, etc.
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Program',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: program.name, year: program.year },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return program
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get wizard config for a program (parsed from settingsJson)
|
||||
*/
|
||||
getWizardConfig: protectedProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
select: { settingsJson: true },
|
||||
})
|
||||
return parseWizardConfig(program.settingsJson)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update wizard config for a program (admin only)
|
||||
*/
|
||||
updateWizardConfig: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
wizardConfig: wizardConfigSchema,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
select: { settingsJson: true },
|
||||
})
|
||||
|
||||
const currentSettings = (program.settingsJson || {}) as Record<string, unknown>
|
||||
|
||||
const updatedSettings = {
|
||||
...currentSettings,
|
||||
wizardConfig: input.wizardConfig,
|
||||
}
|
||||
|
||||
await ctx.prisma.program.update({
|
||||
where: { id: input.programId },
|
||||
data: {
|
||||
settingsJson: updatedSettings as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Program',
|
||||
entityId: input.programId,
|
||||
detailsJson: {
|
||||
field: 'wizardConfig',
|
||||
stepsEnabled: input.wizardConfig.steps.filter((s) => s.enabled).length,
|
||||
totalSteps: input.wizardConfig.steps.length,
|
||||
customFieldsCount: input.wizardConfig.customFields?.length ?? 0,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,203 +1,203 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
/**
|
||||
* Project Pool Router
|
||||
*
|
||||
* Manages the pool of unassigned projects (projects not yet assigned to any stage).
|
||||
* Provides procedures for listing unassigned projects and bulk assigning them to stages.
|
||||
*/
|
||||
export const projectPoolRouter = router({
|
||||
/**
|
||||
* List unassigned projects with filtering and pagination
|
||||
* Projects not assigned to any stage
|
||||
*/
|
||||
listUnassigned: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(), // Required - must specify which program
|
||||
competitionCategory: z
|
||||
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
|
||||
.optional(),
|
||||
search: z.string().optional(), // Search in title, teamName, description
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(200).default(20),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { programId, competitionCategory, search, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
stageStates: { none: {} }, // Only unassigned projects (not in any stage)
|
||||
}
|
||||
|
||||
// Filter by competition category
|
||||
if (competitionCategory) {
|
||||
where.competitionCategory = competitionCategory
|
||||
}
|
||||
|
||||
// Search in title, teamName, description
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [projects, total] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: perPage,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
oceanIssue: true,
|
||||
country: true,
|
||||
status: true,
|
||||
submittedAt: true,
|
||||
createdAt: true,
|
||||
tags: true,
|
||||
wantsMentorship: true,
|
||||
programId: true,
|
||||
_count: {
|
||||
select: {
|
||||
files: true,
|
||||
teamMembers: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
projects,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk assign projects to a stage
|
||||
*
|
||||
* Validates that:
|
||||
* - All projects exist
|
||||
* - Stage exists
|
||||
*
|
||||
* Creates:
|
||||
* - ProjectStageState entries for each project
|
||||
* - Project.status updated to 'ASSIGNED'
|
||||
* - ProjectStatusHistory records for each project
|
||||
* - Audit log
|
||||
*/
|
||||
assignToStage: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
|
||||
stageId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { projectIds, stageId } = input
|
||||
|
||||
// Step 1: Fetch all projects to validate
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
id: { in: projectIds },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
programId: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Validate all projects were found
|
||||
if (projects.length !== projectIds.length) {
|
||||
const foundIds = new Set(projects.map((p) => p.id))
|
||||
const missingIds = projectIds.filter((id) => !foundIds.has(id))
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Some projects were not found: ${missingIds.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Verify stage exists and get its trackId
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: stageId },
|
||||
select: { id: true, trackId: true },
|
||||
})
|
||||
|
||||
// Step 2: Perform bulk assignment in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create ProjectStageState entries for each project (skip existing)
|
||||
const stageStateData = projectIds.map((projectId) => ({
|
||||
projectId,
|
||||
stageId,
|
||||
trackId: stage.trackId,
|
||||
state: 'PENDING' as const,
|
||||
}))
|
||||
|
||||
await tx.projectStageState.createMany({
|
||||
data: stageStateData,
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
// Update project statuses
|
||||
const updatedProjects = await tx.project.updateMany({
|
||||
where: {
|
||||
id: { in: projectIds },
|
||||
},
|
||||
data: {
|
||||
status: 'ASSIGNED',
|
||||
},
|
||||
})
|
||||
|
||||
// Create status history records for each project
|
||||
await tx.projectStatusHistory.createMany({
|
||||
data: projectIds.map((projectId) => ({
|
||||
projectId,
|
||||
status: 'ASSIGNED',
|
||||
changedBy: ctx.user?.id,
|
||||
})),
|
||||
})
|
||||
|
||||
// Create audit log
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user?.id,
|
||||
action: 'BULK_ASSIGN_TO_STAGE',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
stageId,
|
||||
projectCount: projectIds.length,
|
||||
projectIds,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updatedProjects
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
assignedCount: result.count,
|
||||
stageId,
|
||||
}
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
/**
|
||||
* Project Pool Router
|
||||
*
|
||||
* Manages the pool of unassigned projects (projects not yet assigned to any stage).
|
||||
* Provides procedures for listing unassigned projects and bulk assigning them to stages.
|
||||
*/
|
||||
export const projectPoolRouter = router({
|
||||
/**
|
||||
* List unassigned projects with filtering and pagination
|
||||
* Projects not assigned to any stage
|
||||
*/
|
||||
listUnassigned: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(), // Required - must specify which program
|
||||
competitionCategory: z
|
||||
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
|
||||
.optional(),
|
||||
search: z.string().optional(), // Search in title, teamName, description
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(200).default(20),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { programId, competitionCategory, search, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
stageStates: { none: {} }, // Only unassigned projects (not in any stage)
|
||||
}
|
||||
|
||||
// Filter by competition category
|
||||
if (competitionCategory) {
|
||||
where.competitionCategory = competitionCategory
|
||||
}
|
||||
|
||||
// Search in title, teamName, description
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [projects, total] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: perPage,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
oceanIssue: true,
|
||||
country: true,
|
||||
status: true,
|
||||
submittedAt: true,
|
||||
createdAt: true,
|
||||
tags: true,
|
||||
wantsMentorship: true,
|
||||
programId: true,
|
||||
_count: {
|
||||
select: {
|
||||
files: true,
|
||||
teamMembers: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
projects,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk assign projects to a stage
|
||||
*
|
||||
* Validates that:
|
||||
* - All projects exist
|
||||
* - Stage exists
|
||||
*
|
||||
* Creates:
|
||||
* - ProjectStageState entries for each project
|
||||
* - Project.status updated to 'ASSIGNED'
|
||||
* - ProjectStatusHistory records for each project
|
||||
* - Audit log
|
||||
*/
|
||||
assignToStage: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
|
||||
stageId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { projectIds, stageId } = input
|
||||
|
||||
// Step 1: Fetch all projects to validate
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
id: { in: projectIds },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
programId: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Validate all projects were found
|
||||
if (projects.length !== projectIds.length) {
|
||||
const foundIds = new Set(projects.map((p) => p.id))
|
||||
const missingIds = projectIds.filter((id) => !foundIds.has(id))
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Some projects were not found: ${missingIds.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Verify stage exists and get its trackId
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: stageId },
|
||||
select: { id: true, trackId: true },
|
||||
})
|
||||
|
||||
// Step 2: Perform bulk assignment in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create ProjectStageState entries for each project (skip existing)
|
||||
const stageStateData = projectIds.map((projectId) => ({
|
||||
projectId,
|
||||
stageId,
|
||||
trackId: stage.trackId,
|
||||
state: 'PENDING' as const,
|
||||
}))
|
||||
|
||||
await tx.projectStageState.createMany({
|
||||
data: stageStateData,
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
// Update project statuses
|
||||
const updatedProjects = await tx.project.updateMany({
|
||||
where: {
|
||||
id: { in: projectIds },
|
||||
},
|
||||
data: {
|
||||
status: 'ASSIGNED',
|
||||
},
|
||||
})
|
||||
|
||||
// Create status history records for each project
|
||||
await tx.projectStatusHistory.createMany({
|
||||
data: projectIds.map((projectId) => ({
|
||||
projectId,
|
||||
status: 'ASSIGNED',
|
||||
changedBy: ctx.user?.id,
|
||||
})),
|
||||
})
|
||||
|
||||
// Create audit log
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user?.id,
|
||||
action: 'BULK_ASSIGN_TO_STAGE',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
stageId,
|
||||
projectCount: projectIds.length,
|
||||
projectIds,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updatedProjects
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
assignedCount: result.count,
|
||||
stageId,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { logAudit } from '../utils/audit'
|
||||
import { sendInvitationEmail } from '@/lib/email'
|
||||
|
||||
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
const STATUSES_WITH_TEAM_NOTIFICATIONS = ['SEMIFINALIST', 'FINALIST', 'REJECTED'] as const
|
||||
|
||||
// Valid project status transitions
|
||||
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
||||
@@ -245,6 +246,98 @@ export const projectRouter = router({
|
||||
return { ids: projects.map((p) => p.id) }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Preview project-team recipients before bulk status update notifications.
|
||||
* Used by admin UI confirmation dialog to verify notification audience.
|
||||
*/
|
||||
previewStatusNotificationRecipients: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()).min(1).max(10000),
|
||||
status: z.enum([
|
||||
'SUBMITTED',
|
||||
'ELIGIBLE',
|
||||
'ASSIGNED',
|
||||
'SEMIFINALIST',
|
||||
'FINALIST',
|
||||
'REJECTED',
|
||||
]),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const statusTriggersNotification = STATUSES_WITH_TEAM_NOTIFICATIONS.includes(
|
||||
input.status as (typeof STATUSES_WITH_TEAM_NOTIFICATIONS)[number]
|
||||
)
|
||||
|
||||
if (!statusTriggersNotification) {
|
||||
return {
|
||||
status: input.status,
|
||||
statusTriggersNotification,
|
||||
totalProjects: 0,
|
||||
projectsWithRecipients: 0,
|
||||
totalRecipients: 0,
|
||||
projects: [] as Array<{
|
||||
id: string
|
||||
title: string
|
||||
recipientCount: number
|
||||
recipientsPreview: string[]
|
||||
hasMoreRecipients: boolean
|
||||
}>,
|
||||
}
|
||||
}
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.ids } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamMembers: {
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { title: 'asc' },
|
||||
})
|
||||
|
||||
const MAX_PREVIEW_RECIPIENTS_PER_PROJECT = 8
|
||||
|
||||
const mappedProjects = projects.map((project) => {
|
||||
const uniqueEmails = Array.from(
|
||||
new Set(
|
||||
project.teamMembers
|
||||
.map((member) => member.user?.email?.toLowerCase().trim() ?? '')
|
||||
.filter((email) => email.length > 0)
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
recipientCount: uniqueEmails.length,
|
||||
recipientsPreview: uniqueEmails.slice(0, MAX_PREVIEW_RECIPIENTS_PER_PROJECT),
|
||||
hasMoreRecipients: uniqueEmails.length > MAX_PREVIEW_RECIPIENTS_PER_PROJECT,
|
||||
}
|
||||
})
|
||||
|
||||
const projectsWithRecipients = mappedProjects.filter((p) => p.recipientCount > 0).length
|
||||
const totalRecipients = mappedProjects.reduce((sum, project) => sum + project.recipientCount, 0)
|
||||
|
||||
return {
|
||||
status: input.status,
|
||||
statusTriggersNotification,
|
||||
totalProjects: mappedProjects.length,
|
||||
projectsWithRecipients,
|
||||
totalRecipients,
|
||||
projects: mappedProjects,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get filter options for the project list (distinct values)
|
||||
*/
|
||||
|
||||
@@ -1,291 +1,383 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import {
|
||||
previewRouting,
|
||||
evaluateRoutingRules,
|
||||
executeRouting,
|
||||
} from '@/server/services/routing-engine'
|
||||
|
||||
export const routingRouter = router({
|
||||
/**
|
||||
* Preview routing: show where projects would land without executing.
|
||||
* Delegates to routing-engine service for proper predicate evaluation.
|
||||
*/
|
||||
preview: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(500),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const results = await previewRouting(
|
||||
input.projectIds,
|
||||
input.pipelineId,
|
||||
ctx.prisma
|
||||
)
|
||||
|
||||
return {
|
||||
pipelineId: input.pipelineId,
|
||||
totalProjects: results.length,
|
||||
results: results.map((r) => ({
|
||||
projectId: r.projectId,
|
||||
projectTitle: r.projectTitle,
|
||||
matchedRuleId: r.matchedRule?.ruleId ?? null,
|
||||
matchedRuleName: r.matchedRule?.ruleName ?? null,
|
||||
targetTrackId: r.matchedRule?.destinationTrackId ?? null,
|
||||
targetTrackName: null as string | null,
|
||||
targetStageId: r.matchedRule?.destinationStageId ?? null,
|
||||
targetStageName: null as string | null,
|
||||
routingMode: r.matchedRule?.routingMode ?? null,
|
||||
reason: r.reason,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Execute routing: evaluate rules and move projects into tracks/stages.
|
||||
* Delegates to routing-engine service which enforces PARALLEL/EXCLUSIVE/POST_MAIN modes.
|
||||
*/
|
||||
execute: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(500),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify pipeline is ACTIVE
|
||||
const pipeline = await ctx.prisma.pipeline.findUniqueOrThrow({
|
||||
where: { id: input.pipelineId },
|
||||
})
|
||||
|
||||
if (pipeline.status !== 'ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Pipeline must be ACTIVE to route projects',
|
||||
})
|
||||
}
|
||||
|
||||
// Load projects to get their current active stage states
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
projectStageStates: {
|
||||
where: { exitedAt: null },
|
||||
select: { stageId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No matching projects found',
|
||||
})
|
||||
}
|
||||
|
||||
let routedCount = 0
|
||||
let skippedCount = 0
|
||||
const errors: Array<{ projectId: string; error: string }> = []
|
||||
|
||||
for (const project of projects) {
|
||||
const activePSS = project.projectStageStates[0]
|
||||
if (!activePSS) {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Evaluate routing rules using the service
|
||||
const matchedRule = await evaluateRoutingRules(
|
||||
project.id,
|
||||
activePSS.stageId,
|
||||
input.pipelineId,
|
||||
ctx.prisma
|
||||
)
|
||||
|
||||
if (!matchedRule) {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Execute routing using the service (handles PARALLEL/EXCLUSIVE/POST_MAIN)
|
||||
const result = await executeRouting(
|
||||
project.id,
|
||||
matchedRule,
|
||||
ctx.user.id,
|
||||
ctx.prisma
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
routedCount++
|
||||
} else {
|
||||
skippedCount++
|
||||
if (result.errors?.length) {
|
||||
errors.push({ projectId: project.id, error: result.errors[0] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record batch-level audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'ROUTING_EXECUTED',
|
||||
entityType: 'Pipeline',
|
||||
entityId: input.pipelineId,
|
||||
detailsJson: {
|
||||
projectCount: projects.length,
|
||||
routedCount,
|
||||
skippedCount,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { routedCount, skippedCount, totalProjects: projects.length }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List routing rules for a pipeline
|
||||
*/
|
||||
listRules: adminProcedure
|
||||
.input(z.object({ pipelineId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.routingRule.findMany({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
orderBy: [{ isActive: 'desc' }, { priority: 'desc' }],
|
||||
include: {
|
||||
sourceTrack: { select: { id: true, name: true } },
|
||||
destinationTrack: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create or update a routing rule
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import {
|
||||
previewRouting,
|
||||
evaluateRoutingRules,
|
||||
executeRouting,
|
||||
} from '@/server/services/routing-engine'
|
||||
|
||||
export const routingRouter = router({
|
||||
/**
|
||||
* Preview routing: show where projects would land without executing.
|
||||
* Delegates to routing-engine service for proper predicate evaluation.
|
||||
*/
|
||||
preview: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(500),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const results = await previewRouting(
|
||||
input.projectIds,
|
||||
input.pipelineId,
|
||||
ctx.prisma
|
||||
)
|
||||
|
||||
return {
|
||||
pipelineId: input.pipelineId,
|
||||
totalProjects: results.length,
|
||||
results: results.map((r) => ({
|
||||
projectId: r.projectId,
|
||||
projectTitle: r.projectTitle,
|
||||
matchedRuleId: r.matchedRule?.ruleId ?? null,
|
||||
matchedRuleName: r.matchedRule?.ruleName ?? null,
|
||||
targetTrackId: r.matchedRule?.destinationTrackId ?? null,
|
||||
targetTrackName: null as string | null,
|
||||
targetStageId: r.matchedRule?.destinationStageId ?? null,
|
||||
targetStageName: null as string | null,
|
||||
routingMode: r.matchedRule?.routingMode ?? null,
|
||||
reason: r.reason,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Execute routing: evaluate rules and move projects into tracks/stages.
|
||||
* Delegates to routing-engine service which enforces PARALLEL/EXCLUSIVE/POST_MAIN modes.
|
||||
*/
|
||||
execute: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
projectIds: z.array(z.string()).min(1).max(500),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify pipeline is ACTIVE
|
||||
const pipeline = await ctx.prisma.pipeline.findUniqueOrThrow({
|
||||
where: { id: input.pipelineId },
|
||||
})
|
||||
|
||||
if (pipeline.status !== 'ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Pipeline must be ACTIVE to route projects',
|
||||
})
|
||||
}
|
||||
|
||||
// Load projects to get their current active stage states
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
projectStageStates: {
|
||||
where: { exitedAt: null },
|
||||
select: { stageId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No matching projects found',
|
||||
})
|
||||
}
|
||||
|
||||
let routedCount = 0
|
||||
let skippedCount = 0
|
||||
const errors: Array<{ projectId: string; error: string }> = []
|
||||
|
||||
for (const project of projects) {
|
||||
const activePSS = project.projectStageStates[0]
|
||||
if (!activePSS) {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Evaluate routing rules using the service
|
||||
const matchedRule = await evaluateRoutingRules(
|
||||
project.id,
|
||||
activePSS.stageId,
|
||||
input.pipelineId,
|
||||
ctx.prisma
|
||||
)
|
||||
|
||||
if (!matchedRule) {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Execute routing using the service (handles PARALLEL/EXCLUSIVE/POST_MAIN)
|
||||
const result = await executeRouting(
|
||||
project.id,
|
||||
matchedRule,
|
||||
ctx.user.id,
|
||||
ctx.prisma
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
routedCount++
|
||||
} else {
|
||||
skippedCount++
|
||||
if (result.errors?.length) {
|
||||
errors.push({ projectId: project.id, error: result.errors[0] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record batch-level audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'ROUTING_EXECUTED',
|
||||
entityType: 'Pipeline',
|
||||
entityId: input.pipelineId,
|
||||
detailsJson: {
|
||||
projectCount: projects.length,
|
||||
routedCount,
|
||||
skippedCount,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { routedCount, skippedCount, totalProjects: projects.length }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List routing rules for a pipeline
|
||||
*/
|
||||
listRules: adminProcedure
|
||||
.input(z.object({ pipelineId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.routingRule.findMany({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
orderBy: [{ isActive: 'desc' }, { priority: 'desc' }],
|
||||
include: {
|
||||
sourceTrack: { select: { id: true, name: true } },
|
||||
destinationTrack: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create or update a routing rule
|
||||
*/
|
||||
upsertRule: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(), // If provided, update existing
|
||||
pipelineId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
scope: z.enum(['global', 'track', 'stage']).default('global'),
|
||||
sourceTrackId: z.string().optional().nullable(),
|
||||
destinationTrackId: z.string(),
|
||||
destinationStageId: z.string().optional().nullable(),
|
||||
predicateJson: z.record(z.unknown()),
|
||||
priority: z.number().int().min(0).max(1000).default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, predicateJson, ...data } = input
|
||||
|
||||
// Verify destination track exists in this pipeline
|
||||
const destTrack = await ctx.prisma.track.findFirst({
|
||||
where: { id: input.destinationTrackId, pipelineId: input.pipelineId },
|
||||
})
|
||||
if (!destTrack) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Destination track must belong to the same pipeline',
|
||||
})
|
||||
}
|
||||
|
||||
if (id) {
|
||||
// Update existing rule
|
||||
const rule = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.routingRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
predicateJson: predicateJson as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: id,
|
||||
detailsJson: { name: input.name, priority: input.priority },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return rule
|
||||
} else {
|
||||
// Create new rule
|
||||
const rule = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.routingRule.create({
|
||||
data: {
|
||||
...data,
|
||||
predicateJson: predicateJson as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, priority: input.priority },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(), // If provided, update existing
|
||||
pipelineId: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
scope: z.enum(['global', 'track', 'stage']).default('global'),
|
||||
sourceTrackId: z.string().optional().nullable(),
|
||||
destinationTrackId: z.string(),
|
||||
destinationStageId: z.string().optional().nullable(),
|
||||
predicateJson: z.record(z.unknown()),
|
||||
priority: z.number().int().min(0).max(1000).default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, predicateJson, ...data } = input
|
||||
|
||||
// Verify destination track exists in this pipeline
|
||||
const destTrack = await ctx.prisma.track.findFirst({
|
||||
where: { id: input.destinationTrackId, pipelineId: input.pipelineId },
|
||||
})
|
||||
if (!destTrack) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Destination track must belong to the same pipeline',
|
||||
})
|
||||
}
|
||||
|
||||
if (id) {
|
||||
// Update existing rule
|
||||
const rule = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.routingRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
predicateJson: predicateJson as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: id,
|
||||
detailsJson: { name: input.name, priority: input.priority },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return rule
|
||||
} else {
|
||||
// Create new rule
|
||||
const rule = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.routingRule.create({
|
||||
data: {
|
||||
...data,
|
||||
predicateJson: predicateJson as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, priority: input.priority },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return rule
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Toggle a routing rule on/off
|
||||
* Delete a routing rule
|
||||
*/
|
||||
toggleRule: adminProcedure
|
||||
deleteRule: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rule = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.routingRule.update({
|
||||
const existing = await ctx.prisma.routingRule.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: { id: true, name: true, pipelineId: true },
|
||||
})
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.routingRule.delete({
|
||||
where: { id: input.id },
|
||||
data: { isActive: input.isActive },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: input.isActive ? 'ROUTING_RULE_ENABLED' : 'ROUTING_RULE_DISABLED',
|
||||
action: 'DELETE',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: input.id,
|
||||
detailsJson: { isActive: input.isActive, name: updated.name },
|
||||
detailsJson: { name: existing.name, pipelineId: existing.pipelineId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return rule
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Reorder routing rules by priority (highest first)
|
||||
*/
|
||||
reorderRules: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pipelineId: z.string(),
|
||||
orderedIds: z.array(z.string()).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rules = await ctx.prisma.routingRule.findMany({
|
||||
where: { pipelineId: input.pipelineId },
|
||||
select: { id: true },
|
||||
})
|
||||
const ruleIds = new Set(rules.map((rule) => rule.id))
|
||||
|
||||
for (const id of input.orderedIds) {
|
||||
if (!ruleIds.has(id)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Routing rule ${id} does not belong to this pipeline`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
const maxPriority = input.orderedIds.length
|
||||
await Promise.all(
|
||||
input.orderedIds.map((id, index) =>
|
||||
tx.routingRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
priority: maxPriority - index,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Pipeline',
|
||||
entityId: input.pipelineId,
|
||||
detailsJson: {
|
||||
action: 'ROUTING_RULES_REORDERED',
|
||||
ruleCount: input.orderedIds.length,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Toggle a routing rule on/off
|
||||
*/
|
||||
toggleRule: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rule = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.routingRule.update({
|
||||
where: { id: input.id },
|
||||
data: { isActive: input.isActive },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: input.isActive ? 'ROUTING_RULE_ENABLED' : 'ROUTING_RULE_DISABLED',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: input.id,
|
||||
detailsJson: { isActive: input.isActive, name: updated.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return rule
|
||||
}),
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,257 +1,257 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import {
|
||||
testTypeformConnection,
|
||||
getTypeformSchema,
|
||||
getAllTypeformResponses,
|
||||
responseToObject,
|
||||
} from '@/lib/typeform'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
|
||||
export const typeformImportRouter = router({
|
||||
/**
|
||||
* Test connection to Typeform API
|
||||
*/
|
||||
testConnection: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return testTypeformConnection(input.apiKey)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get form schema (questions/fields) for mapping
|
||||
*/
|
||||
getFormSchema: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
formId: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
return await getTypeformSchema(input.apiKey, input.formId)
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch form schema',
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Preview responses from Typeform
|
||||
*/
|
||||
previewResponses: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
formId: z.string().min(1),
|
||||
limit: z.number().int().min(1).max(10).default(5),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const schema = await getTypeformSchema(input.apiKey, input.formId)
|
||||
const responses = await getAllTypeformResponses(
|
||||
input.apiKey,
|
||||
input.formId,
|
||||
input.limit
|
||||
)
|
||||
|
||||
// Convert responses to flat objects for preview
|
||||
const records = responses.map((r) => responseToObject(r, schema.fields))
|
||||
|
||||
return {
|
||||
records,
|
||||
count: records.length,
|
||||
formTitle: schema.title,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch responses from Typeform',
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Import projects from Typeform responses
|
||||
*/
|
||||
importProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
formId: z.string().min(1),
|
||||
programId: z.string(),
|
||||
mappings: z.object({
|
||||
title: z.string(),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
}),
|
||||
includeUnmappedInMetadata: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
})
|
||||
|
||||
// Fetch form schema and all responses
|
||||
const schema = await getTypeformSchema(input.apiKey, input.formId)
|
||||
const responses = await getAllTypeformResponses(input.apiKey, input.formId)
|
||||
|
||||
if (responses.length === 0) {
|
||||
return { imported: 0, skipped: 0, errors: [] }
|
||||
}
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: [] as Array<{ responseId: string; error: string }>,
|
||||
}
|
||||
|
||||
// Process each response
|
||||
for (const response of responses) {
|
||||
try {
|
||||
const record = responseToObject(response, schema.fields)
|
||||
|
||||
// Get mapped values
|
||||
const title = record[input.mappings.title]
|
||||
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
results.errors.push({
|
||||
responseId: response.response_id,
|
||||
error: 'Missing or invalid title',
|
||||
})
|
||||
results.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const teamName = input.mappings.teamName
|
||||
? record[input.mappings.teamName]
|
||||
: null
|
||||
|
||||
const description = input.mappings.description
|
||||
? record[input.mappings.description]
|
||||
: null
|
||||
|
||||
let tags: string[] = []
|
||||
if (input.mappings.tags) {
|
||||
const tagsValue = record[input.mappings.tags]
|
||||
if (Array.isArray(tagsValue)) {
|
||||
tags = tagsValue.filter((t): t is string => typeof t === 'string')
|
||||
} else if (typeof tagsValue === 'string') {
|
||||
tags = tagsValue.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Get country and normalize to ISO code
|
||||
let country: string | null = null
|
||||
if (input.mappings.country) {
|
||||
const countryValue = record[input.mappings.country]
|
||||
if (typeof countryValue === 'string') {
|
||||
country = normalizeCountryToCode(countryValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Build metadata from unmapped columns
|
||||
let metadataJson: Record<string, unknown> | null = null
|
||||
if (input.includeUnmappedInMetadata) {
|
||||
const mappedKeys = new Set([
|
||||
input.mappings.title,
|
||||
input.mappings.teamName,
|
||||
input.mappings.description,
|
||||
input.mappings.tags,
|
||||
input.mappings.email,
|
||||
input.mappings.country,
|
||||
'_response_id',
|
||||
'_submitted_at',
|
||||
].filter(Boolean))
|
||||
|
||||
metadataJson = {}
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!mappedKeys.has(key) && value !== null && value !== undefined) {
|
||||
metadataJson[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Add submission email if mapped
|
||||
if (input.mappings.email) {
|
||||
const email = record[input.mappings.email]
|
||||
if (email) {
|
||||
metadataJson._submissionEmail = email
|
||||
}
|
||||
}
|
||||
|
||||
// Add submission timestamp
|
||||
metadataJson._submittedAt = response.submitted_at
|
||||
|
||||
if (Object.keys(metadataJson).length === 0) {
|
||||
metadataJson = null
|
||||
}
|
||||
}
|
||||
|
||||
// Create project
|
||||
await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: input.programId,
|
||||
status: 'SUBMITTED',
|
||||
title: String(title).trim(),
|
||||
teamName: typeof teamName === 'string' ? teamName.trim() : null,
|
||||
description: typeof description === 'string' ? description : null,
|
||||
tags,
|
||||
country,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
externalIdsJson: {
|
||||
typeformResponseId: response.response_id,
|
||||
typeformFormId: input.formId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
results.imported++
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
responseId: response.response_id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
results.skipped++
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
source: 'typeform',
|
||||
formId: input.formId,
|
||||
imported: results.imported,
|
||||
skipped: results.skipped,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return results
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import {
|
||||
testTypeformConnection,
|
||||
getTypeformSchema,
|
||||
getAllTypeformResponses,
|
||||
responseToObject,
|
||||
} from '@/lib/typeform'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
|
||||
export const typeformImportRouter = router({
|
||||
/**
|
||||
* Test connection to Typeform API
|
||||
*/
|
||||
testConnection: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return testTypeformConnection(input.apiKey)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get form schema (questions/fields) for mapping
|
||||
*/
|
||||
getFormSchema: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
formId: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
return await getTypeformSchema(input.apiKey, input.formId)
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch form schema',
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Preview responses from Typeform
|
||||
*/
|
||||
previewResponses: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
formId: z.string().min(1),
|
||||
limit: z.number().int().min(1).max(10).default(5),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const schema = await getTypeformSchema(input.apiKey, input.formId)
|
||||
const responses = await getAllTypeformResponses(
|
||||
input.apiKey,
|
||||
input.formId,
|
||||
input.limit
|
||||
)
|
||||
|
||||
// Convert responses to flat objects for preview
|
||||
const records = responses.map((r) => responseToObject(r, schema.fields))
|
||||
|
||||
return {
|
||||
records,
|
||||
count: records.length,
|
||||
formTitle: schema.title,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch responses from Typeform',
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Import projects from Typeform responses
|
||||
*/
|
||||
importProjects: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
formId: z.string().min(1),
|
||||
programId: z.string(),
|
||||
mappings: z.object({
|
||||
title: z.string(),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
}),
|
||||
includeUnmappedInMetadata: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
})
|
||||
|
||||
// Fetch form schema and all responses
|
||||
const schema = await getTypeformSchema(input.apiKey, input.formId)
|
||||
const responses = await getAllTypeformResponses(input.apiKey, input.formId)
|
||||
|
||||
if (responses.length === 0) {
|
||||
return { imported: 0, skipped: 0, errors: [] }
|
||||
}
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: [] as Array<{ responseId: string; error: string }>,
|
||||
}
|
||||
|
||||
// Process each response
|
||||
for (const response of responses) {
|
||||
try {
|
||||
const record = responseToObject(response, schema.fields)
|
||||
|
||||
// Get mapped values
|
||||
const title = record[input.mappings.title]
|
||||
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
results.errors.push({
|
||||
responseId: response.response_id,
|
||||
error: 'Missing or invalid title',
|
||||
})
|
||||
results.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const teamName = input.mappings.teamName
|
||||
? record[input.mappings.teamName]
|
||||
: null
|
||||
|
||||
const description = input.mappings.description
|
||||
? record[input.mappings.description]
|
||||
: null
|
||||
|
||||
let tags: string[] = []
|
||||
if (input.mappings.tags) {
|
||||
const tagsValue = record[input.mappings.tags]
|
||||
if (Array.isArray(tagsValue)) {
|
||||
tags = tagsValue.filter((t): t is string => typeof t === 'string')
|
||||
} else if (typeof tagsValue === 'string') {
|
||||
tags = tagsValue.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Get country and normalize to ISO code
|
||||
let country: string | null = null
|
||||
if (input.mappings.country) {
|
||||
const countryValue = record[input.mappings.country]
|
||||
if (typeof countryValue === 'string') {
|
||||
country = normalizeCountryToCode(countryValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Build metadata from unmapped columns
|
||||
let metadataJson: Record<string, unknown> | null = null
|
||||
if (input.includeUnmappedInMetadata) {
|
||||
const mappedKeys = new Set([
|
||||
input.mappings.title,
|
||||
input.mappings.teamName,
|
||||
input.mappings.description,
|
||||
input.mappings.tags,
|
||||
input.mappings.email,
|
||||
input.mappings.country,
|
||||
'_response_id',
|
||||
'_submitted_at',
|
||||
].filter(Boolean))
|
||||
|
||||
metadataJson = {}
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!mappedKeys.has(key) && value !== null && value !== undefined) {
|
||||
metadataJson[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Add submission email if mapped
|
||||
if (input.mappings.email) {
|
||||
const email = record[input.mappings.email]
|
||||
if (email) {
|
||||
metadataJson._submissionEmail = email
|
||||
}
|
||||
}
|
||||
|
||||
// Add submission timestamp
|
||||
metadataJson._submittedAt = response.submitted_at
|
||||
|
||||
if (Object.keys(metadataJson).length === 0) {
|
||||
metadataJson = null
|
||||
}
|
||||
}
|
||||
|
||||
// Create project
|
||||
await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: input.programId,
|
||||
status: 'SUBMITTED',
|
||||
title: String(title).trim(),
|
||||
teamName: typeof teamName === 'string' ? teamName.trim() : null,
|
||||
description: typeof description === 'string' ? description : null,
|
||||
tags,
|
||||
country,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
externalIdsJson: {
|
||||
typeformResponseId: response.response_id,
|
||||
typeformFormId: input.formId,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
results.imported++
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
responseId: response.response_id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
results.skipped++
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
source: 'typeform',
|
||||
formId: input.formId,
|
||||
imported: results.imported,
|
||||
skipped: results.skipped,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return results
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -87,6 +87,7 @@ export const userRouter = router({
|
||||
updateProfile: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
bio: z.string().max(1000).optional(),
|
||||
phoneNumber: z.string().max(20).optional().nullable(),
|
||||
@@ -98,7 +99,34 @@ export const userRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { bio, expertiseTags, availabilityJson, preferredWorkload, digestFrequency, ...directFields } = input
|
||||
const {
|
||||
bio,
|
||||
expertiseTags,
|
||||
availabilityJson,
|
||||
preferredWorkload,
|
||||
digestFrequency,
|
||||
email,
|
||||
...directFields
|
||||
} = input
|
||||
|
||||
const normalizedEmail = email?.toLowerCase().trim()
|
||||
|
||||
if (normalizedEmail !== undefined) {
|
||||
const existing = await ctx.prisma.user.findFirst({
|
||||
where: {
|
||||
email: normalizedEmail,
|
||||
NOT: { id: ctx.user.id },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Another account already uses this email address',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If bio is provided, merge it into metadataJson
|
||||
let metadataJson: Prisma.InputJsonValue | undefined
|
||||
@@ -115,6 +143,7 @@ export const userRouter = router({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
...directFields,
|
||||
...(normalizedEmail !== undefined && { email: normalizedEmail }),
|
||||
...(metadataJson !== undefined && { metadataJson }),
|
||||
...(expertiseTags !== undefined && { expertiseTags }),
|
||||
...(digestFrequency !== undefined && { digestFrequency }),
|
||||
@@ -258,6 +287,46 @@ export const userRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* List all invitable user IDs for current filters (not paginated)
|
||||
*/
|
||||
listInvitableIds: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {
|
||||
status: { in: ['NONE', 'INVITED'] },
|
||||
}
|
||||
|
||||
if (input.roles && input.roles.length > 0) {
|
||||
where.role = { in: input.roles }
|
||||
} else if (input.role) {
|
||||
where.role = input.role
|
||||
}
|
||||
|
||||
if (input.search) {
|
||||
where.OR = [
|
||||
{ email: { contains: input.search, mode: 'insensitive' } },
|
||||
{ name: { contains: input.search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return {
|
||||
userIds: users.map((u) => u.id),
|
||||
total: users.length,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single user (admin only)
|
||||
*/
|
||||
@@ -347,6 +416,7 @@ export const userRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().optional().nullable(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
@@ -358,6 +428,7 @@ export const userRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
const normalizedEmail = data.email?.toLowerCase().trim()
|
||||
|
||||
// Prevent changing super admin role
|
||||
const targetUser = await ctx.prisma.user.findUniqueOrThrow({
|
||||
@@ -393,10 +464,32 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedEmail !== undefined) {
|
||||
const existing = await ctx.prisma.user.findFirst({
|
||||
where: {
|
||||
email: normalizedEmail,
|
||||
NOT: { id },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Another user already uses this email address',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
...data,
|
||||
...(normalizedEmail !== undefined && { email: normalizedEmail }),
|
||||
}
|
||||
|
||||
const user = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
@@ -405,7 +498,7 @@ export const userRouter = router({
|
||||
action: 'UPDATE',
|
||||
entityType: 'User',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
detailsJson: updateData,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
@@ -1,306 +1,306 @@
|
||||
import { z } from 'zod'
|
||||
import { router, superAdminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import {
|
||||
generateWebhookSecret,
|
||||
deliverWebhook,
|
||||
} from '@/server/services/webhook-dispatcher'
|
||||
|
||||
export const WEBHOOK_EVENTS = [
|
||||
'evaluation.submitted',
|
||||
'evaluation.updated',
|
||||
'project.created',
|
||||
'project.statusChanged',
|
||||
'round.activated',
|
||||
'round.closed',
|
||||
'stage.activated',
|
||||
'stage.closed',
|
||||
'assignment.created',
|
||||
'assignment.completed',
|
||||
'user.invited',
|
||||
'user.activated',
|
||||
] as const
|
||||
|
||||
export const webhookRouter = router({
|
||||
/**
|
||||
* List all webhooks with delivery stats.
|
||||
*/
|
||||
list: superAdminProcedure.query(async ({ ctx }) => {
|
||||
const webhooks = await ctx.prisma.webhook.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { deliveries: true },
|
||||
},
|
||||
createdBy: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// Compute recent delivery stats for each webhook
|
||||
const now = new Date()
|
||||
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
|
||||
const stats = await Promise.all(
|
||||
webhooks.map(async (wh) => {
|
||||
const [delivered, failed] = await Promise.all([
|
||||
ctx.prisma.webhookDelivery.count({
|
||||
where: {
|
||||
webhookId: wh.id,
|
||||
status: 'DELIVERED',
|
||||
createdAt: { gte: twentyFourHoursAgo },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.webhookDelivery.count({
|
||||
where: {
|
||||
webhookId: wh.id,
|
||||
status: 'FAILED',
|
||||
createdAt: { gte: twentyFourHoursAgo },
|
||||
},
|
||||
}),
|
||||
])
|
||||
return {
|
||||
...wh,
|
||||
recentDelivered: delivered,
|
||||
recentFailed: failed,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return stats
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new webhook.
|
||||
*/
|
||||
create: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url(),
|
||||
events: z.array(z.string()).min(1),
|
||||
headers: z.any().optional(),
|
||||
maxRetries: z.number().int().min(0).max(10).default(3),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secret = generateWebhookSecret()
|
||||
|
||||
const webhook = await ctx.prisma.webhook.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret,
|
||||
events: input.events,
|
||||
headers: input.headers ?? undefined,
|
||||
maxRetries: input.maxRetries,
|
||||
createdById: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE_WEBHOOK',
|
||||
entityType: 'Webhook',
|
||||
entityId: webhook.id,
|
||||
detailsJson: { name: input.name, url: input.url, events: input.events },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return webhook
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a webhook.
|
||||
*/
|
||||
update: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
url: z.string().url().optional(),
|
||||
events: z.array(z.string()).min(1).optional(),
|
||||
headers: z.any().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
maxRetries: z.number().int().min(0).max(10).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const webhook = await ctx.prisma.webhook.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.name !== undefined ? { name: data.name } : {}),
|
||||
...(data.url !== undefined ? { url: data.url } : {}),
|
||||
...(data.events !== undefined ? { events: data.events } : {}),
|
||||
...(data.headers !== undefined ? { headers: data.headers } : {}),
|
||||
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
|
||||
...(data.maxRetries !== undefined ? { maxRetries: data.maxRetries } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_WEBHOOK',
|
||||
entityType: 'Webhook',
|
||||
entityId: id,
|
||||
detailsJson: { updatedFields: Object.keys(data) },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return webhook
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a webhook and its delivery history.
|
||||
*/
|
||||
delete: superAdminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Cascade delete is defined in schema, so just delete the webhook
|
||||
await ctx.prisma.webhook.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE_WEBHOOK',
|
||||
entityType: 'Webhook',
|
||||
entityId: input.id,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send a test payload to a webhook.
|
||||
*/
|
||||
test: superAdminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const webhook = await ctx.prisma.webhook.findUnique({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!webhook) {
|
||||
throw new Error('Webhook not found')
|
||||
}
|
||||
|
||||
const testPayload = {
|
||||
event: 'test',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
message: 'This is a test webhook delivery from MOPC Platform.',
|
||||
webhookId: webhook.id,
|
||||
webhookName: webhook.name,
|
||||
},
|
||||
}
|
||||
|
||||
const delivery = await ctx.prisma.webhookDelivery.create({
|
||||
data: {
|
||||
webhookId: webhook.id,
|
||||
event: 'test',
|
||||
payload: testPayload,
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await deliverWebhook(delivery.id)
|
||||
|
||||
// Fetch updated delivery to get the result
|
||||
const result = await ctx.prisma.webhookDelivery.findUnique({
|
||||
where: { id: delivery.id },
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'TEST_WEBHOOK',
|
||||
entityType: 'Webhook',
|
||||
entityId: input.id,
|
||||
detailsJson: { deliveryStatus: result?.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get paginated delivery log for a webhook.
|
||||
*/
|
||||
getDeliveryLog: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
webhookId: z.string(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const skip = (input.page - 1) * input.pageSize
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
ctx.prisma.webhookDelivery.findMany({
|
||||
where: { webhookId: input.webhookId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: input.pageSize,
|
||||
}),
|
||||
ctx.prisma.webhookDelivery.count({
|
||||
where: { webhookId: input.webhookId },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: input.page,
|
||||
pageSize: input.pageSize,
|
||||
totalPages: Math.ceil(total / input.pageSize),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Regenerate the HMAC secret for a webhook.
|
||||
*/
|
||||
regenerateSecret: superAdminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const newSecret = generateWebhookSecret()
|
||||
|
||||
const webhook = await ctx.prisma.webhook.update({
|
||||
where: { id: input.id },
|
||||
data: { secret: newSecret },
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REGENERATE_WEBHOOK_SECRET',
|
||||
entityType: 'Webhook',
|
||||
entityId: input.id,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return webhook
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get available webhook events.
|
||||
*/
|
||||
getAvailableEvents: superAdminProcedure.query(() => {
|
||||
return WEBHOOK_EVENTS
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import { router, superAdminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import {
|
||||
generateWebhookSecret,
|
||||
deliverWebhook,
|
||||
} from '@/server/services/webhook-dispatcher'
|
||||
|
||||
export const WEBHOOK_EVENTS = [
|
||||
'evaluation.submitted',
|
||||
'evaluation.updated',
|
||||
'project.created',
|
||||
'project.statusChanged',
|
||||
'round.activated',
|
||||
'round.closed',
|
||||
'stage.activated',
|
||||
'stage.closed',
|
||||
'assignment.created',
|
||||
'assignment.completed',
|
||||
'user.invited',
|
||||
'user.activated',
|
||||
] as const
|
||||
|
||||
export const webhookRouter = router({
|
||||
/**
|
||||
* List all webhooks with delivery stats.
|
||||
*/
|
||||
list: superAdminProcedure.query(async ({ ctx }) => {
|
||||
const webhooks = await ctx.prisma.webhook.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { deliveries: true },
|
||||
},
|
||||
createdBy: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// Compute recent delivery stats for each webhook
|
||||
const now = new Date()
|
||||
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
|
||||
const stats = await Promise.all(
|
||||
webhooks.map(async (wh) => {
|
||||
const [delivered, failed] = await Promise.all([
|
||||
ctx.prisma.webhookDelivery.count({
|
||||
where: {
|
||||
webhookId: wh.id,
|
||||
status: 'DELIVERED',
|
||||
createdAt: { gte: twentyFourHoursAgo },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.webhookDelivery.count({
|
||||
where: {
|
||||
webhookId: wh.id,
|
||||
status: 'FAILED',
|
||||
createdAt: { gte: twentyFourHoursAgo },
|
||||
},
|
||||
}),
|
||||
])
|
||||
return {
|
||||
...wh,
|
||||
recentDelivered: delivered,
|
||||
recentFailed: failed,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return stats
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new webhook.
|
||||
*/
|
||||
create: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url(),
|
||||
events: z.array(z.string()).min(1),
|
||||
headers: z.any().optional(),
|
||||
maxRetries: z.number().int().min(0).max(10).default(3),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secret = generateWebhookSecret()
|
||||
|
||||
const webhook = await ctx.prisma.webhook.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret,
|
||||
events: input.events,
|
||||
headers: input.headers ?? undefined,
|
||||
maxRetries: input.maxRetries,
|
||||
createdById: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE_WEBHOOK',
|
||||
entityType: 'Webhook',
|
||||
entityId: webhook.id,
|
||||
detailsJson: { name: input.name, url: input.url, events: input.events },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return webhook
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a webhook.
|
||||
*/
|
||||
update: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
url: z.string().url().optional(),
|
||||
events: z.array(z.string()).min(1).optional(),
|
||||
headers: z.any().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
maxRetries: z.number().int().min(0).max(10).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const webhook = await ctx.prisma.webhook.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.name !== undefined ? { name: data.name } : {}),
|
||||
...(data.url !== undefined ? { url: data.url } : {}),
|
||||
...(data.events !== undefined ? { events: data.events } : {}),
|
||||
...(data.headers !== undefined ? { headers: data.headers } : {}),
|
||||
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
|
||||
...(data.maxRetries !== undefined ? { maxRetries: data.maxRetries } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_WEBHOOK',
|
||||
entityType: 'Webhook',
|
||||
entityId: id,
|
||||
detailsJson: { updatedFields: Object.keys(data) },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return webhook
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a webhook and its delivery history.
|
||||
*/
|
||||
delete: superAdminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Cascade delete is defined in schema, so just delete the webhook
|
||||
await ctx.prisma.webhook.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE_WEBHOOK',
|
||||
entityType: 'Webhook',
|
||||
entityId: input.id,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send a test payload to a webhook.
|
||||
*/
|
||||
test: superAdminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const webhook = await ctx.prisma.webhook.findUnique({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!webhook) {
|
||||
throw new Error('Webhook not found')
|
||||
}
|
||||
|
||||
const testPayload = {
|
||||
event: 'test',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
message: 'This is a test webhook delivery from MOPC Platform.',
|
||||
webhookId: webhook.id,
|
||||
webhookName: webhook.name,
|
||||
},
|
||||
}
|
||||
|
||||
const delivery = await ctx.prisma.webhookDelivery.create({
|
||||
data: {
|
||||
webhookId: webhook.id,
|
||||
event: 'test',
|
||||
payload: testPayload,
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await deliverWebhook(delivery.id)
|
||||
|
||||
// Fetch updated delivery to get the result
|
||||
const result = await ctx.prisma.webhookDelivery.findUnique({
|
||||
where: { id: delivery.id },
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'TEST_WEBHOOK',
|
||||
entityType: 'Webhook',
|
||||
entityId: input.id,
|
||||
detailsJson: { deliveryStatus: result?.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get paginated delivery log for a webhook.
|
||||
*/
|
||||
getDeliveryLog: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
webhookId: z.string(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const skip = (input.page - 1) * input.pageSize
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
ctx.prisma.webhookDelivery.findMany({
|
||||
where: { webhookId: input.webhookId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: input.pageSize,
|
||||
}),
|
||||
ctx.prisma.webhookDelivery.count({
|
||||
where: { webhookId: input.webhookId },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: input.page,
|
||||
pageSize: input.pageSize,
|
||||
totalPages: Math.ceil(total / input.pageSize),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Regenerate the HMAC secret for a webhook.
|
||||
*/
|
||||
regenerateSecret: superAdminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const newSecret = generateWebhookSecret()
|
||||
|
||||
const webhook = await ctx.prisma.webhook.update({
|
||||
where: { id: input.id },
|
||||
data: { secret: newSecret },
|
||||
})
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REGENERATE_WEBHOOK_SECRET',
|
||||
entityType: 'Webhook',
|
||||
entityId: input.id,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return webhook
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get available webhook events.
|
||||
*/
|
||||
getAvailableEvents: superAdminProcedure.query(() => {
|
||||
return WEBHOOK_EVENTS
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
import { z } from 'zod'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { wizardConfigSchema } from '@/types/wizard-config'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const wizardTemplateRouter = router({
|
||||
list: adminProcedure
|
||||
.input(z.object({ programId: z.string().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.wizardTemplate.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ isGlobal: true },
|
||||
...(input?.programId ? [{ programId: input.programId }] : []),
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { creator: { select: { name: true } } },
|
||||
})
|
||||
}),
|
||||
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
config: wizardConfigSchema,
|
||||
isGlobal: z.boolean().default(false),
|
||||
programId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const template = await ctx.prisma.wizardTemplate.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
config: input.config as unknown as Prisma.InputJsonValue,
|
||||
isGlobal: input.isGlobal,
|
||||
programId: input.programId,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'WizardTemplate',
|
||||
entityId: template.id,
|
||||
detailsJson: { name: input.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return template
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.wizardTemplate.delete({ where: { id: input.id } })
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'WizardTemplate',
|
||||
entityId: input.id,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
import { z } from 'zod'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { wizardConfigSchema } from '@/types/wizard-config'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const wizardTemplateRouter = router({
|
||||
list: adminProcedure
|
||||
.input(z.object({ programId: z.string().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.wizardTemplate.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ isGlobal: true },
|
||||
...(input?.programId ? [{ programId: input.programId }] : []),
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { creator: { select: { name: true } } },
|
||||
})
|
||||
}),
|
||||
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
config: wizardConfigSchema,
|
||||
isGlobal: z.boolean().default(false),
|
||||
programId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const template = await ctx.prisma.wizardTemplate.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
config: input.config as unknown as Prisma.InputJsonValue,
|
||||
isGlobal: input.isGlobal,
|
||||
programId: input.programId,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'WizardTemplate',
|
||||
entityId: template.id,
|
||||
detailsJson: { name: input.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return template
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.wizardTemplate.delete({ where: { id: input.id } })
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'WizardTemplate',
|
||||
entityId: input.id,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user