Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -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

View File

@@ -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 }
}),
})

View File

@@ -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

View File

@@ -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 },
})),
}
}),
})

View File

@@ -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,
}
}),
})

View File

@@ -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

View File

@@ -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 }
}),
})

View File

@@ -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

View File

@@ -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

View File

@@ -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 []
}
}

View File

@@ -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,
}
}
}),
})

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 }
}),
})

View File

@@ -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,
}
}),
})

View File

@@ -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)
*/

View File

@@ -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

View File

@@ -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
}),
})

View File

@@ -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,
})

View File

@@ -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
}),
})

View File

@@ -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 }
}),
})