Platform review round 2: audit logging migration, nav unification, DB indexes, and UI polish
- Migrate ~41 inline audit log calls to shared logAudit() utility across all routers - Add transaction-aware prisma parameter to logAudit() for atomic operations - Unify jury/mentor/observer navigation into shared RoleNav component - Add composite DB indexes (Evaluation, GracePeriod, AuditLog) for query performance - Fix profile page: consolidate dual save buttons, proper useEffect initialization - Enhance auth error page with MOPC branding and navigation - Improve observer dashboard with prominent read-only badge - Fix DI-3: fetch projects before bulk status update for accurate notifications - Remove unused aiBoost field from smart-assignment scoring - Add shared image-upload utility and structured logger module Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
||||
import { getPresignedUrl } from '@/lib/minio'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// Bucket for applicant submissions
|
||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||
@@ -205,16 +206,15 @@ export const applicantRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
detailsJson: { title: input.title, source: 'applicant_portal' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
detailsJson: { title: input.title, source: 'applicant_portal' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return project
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// Zod schemas for the application form
|
||||
const teamMemberSchema = z.object({
|
||||
@@ -299,20 +300,19 @@ export const applicationRouter = router({
|
||||
}
|
||||
|
||||
// Create audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
detailsJson: {
|
||||
source: 'public_application_form',
|
||||
title: data.projectName,
|
||||
category: data.competitionCategory,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
detailsJson: {
|
||||
source: 'public_application_form',
|
||||
title: data.projectName,
|
||||
category: data.competitionCategory,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Notify applicant of successful submission
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// Background job execution function
|
||||
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
||||
@@ -355,16 +356,15 @@ export const assignmentRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Assignment',
|
||||
entityId: assignment.id,
|
||||
detailsJson: input,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Assignment',
|
||||
entityId: assignment.id,
|
||||
detailsJson: input,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Send notification to the assigned jury member
|
||||
@@ -434,15 +434,14 @@ export const assignmentRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_CREATE',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: { count: result.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_CREATE',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: { count: result.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Send notifications to assigned jury members (grouped by user)
|
||||
@@ -499,7 +498,11 @@ export const assignmentRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
return { created: result.count }
|
||||
return {
|
||||
created: result.count,
|
||||
requested: input.assignments.length,
|
||||
skipped: input.assignments.length - result.count,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -513,19 +516,18 @@ export const assignmentRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Assignment',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
userId: assignment.userId,
|
||||
projectId: assignment.projectId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Assignment',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
userId: assignment.userId,
|
||||
projectId: assignment.projectId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return assignment
|
||||
@@ -542,6 +544,7 @@ export const assignmentRouter = router({
|
||||
completedAssignments,
|
||||
assignmentsByUser,
|
||||
projectCoverage,
|
||||
round,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.assignment.count({
|
||||
@@ -560,13 +563,12 @@ export const assignmentRouter = router({
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { requiredReviews: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { requiredReviews: true },
|
||||
})
|
||||
|
||||
const projectsWithFullCoverage = projectCoverage.filter(
|
||||
(p) => p._count.assignments >= round.requiredReviews
|
||||
).length
|
||||
@@ -854,19 +856,18 @@ export const assignmentRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
count: created.count,
|
||||
usedAI: input.usedAI,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
count: created.count,
|
||||
usedAI: input.usedAI,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Send notifications to assigned jury members
|
||||
@@ -953,18 +954,17 @@ export const assignmentRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'APPLY_SUGGESTIONS',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
count: created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'APPLY_SUGGESTIONS',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
count: created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Send notifications to assigned jury members
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure } from '../trpc'
|
||||
import { generateAvatarKey, type StorageProviderType } from '@/lib/storage'
|
||||
import {
|
||||
getStorageProviderWithType,
|
||||
createStorageProvider,
|
||||
generateAvatarKey,
|
||||
getContentType,
|
||||
isValidImageType,
|
||||
type StorageProviderType,
|
||||
} from '@/lib/storage'
|
||||
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({
|
||||
/**
|
||||
@@ -22,23 +51,12 @@ export const avatarRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate content type
|
||||
if (!isValidImageType(input.contentType)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP' })
|
||||
}
|
||||
|
||||
const userId = ctx.user.id
|
||||
const key = generateAvatarKey(userId, input.fileName)
|
||||
const contentType = getContentType(input.fileName)
|
||||
|
||||
const { provider, providerType } = await getStorageProviderWithType()
|
||||
const uploadUrl = await provider.getUploadUrl(key, contentType)
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
key,
|
||||
providerType, // Return so client can pass it back on confirm
|
||||
}
|
||||
return getImageUploadUrl(
|
||||
ctx.user.id,
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
generateAvatarKey
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -54,38 +72,15 @@ export const avatarRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user.id
|
||||
|
||||
// Use the provider that was used for upload
|
||||
const provider = createStorageProvider(input.providerType)
|
||||
const exists = await provider.objectExists(input.key)
|
||||
if (!exists) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' })
|
||||
}
|
||||
|
||||
// Delete old avatar if exists (from its original provider)
|
||||
const currentUser = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { profileImageKey: true, profileImageProvider: true },
|
||||
await confirmImageUpload(ctx.prisma, avatarConfig, userId, input.key, input.providerType, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
if (currentUser?.profileImageKey) {
|
||||
try {
|
||||
const oldProvider = createStorageProvider(
|
||||
(currentUser.profileImageProvider as StorageProviderType) || 's3'
|
||||
)
|
||||
await oldProvider.deleteObject(currentUser.profileImageKey)
|
||||
} catch (error) {
|
||||
// Log but don't fail if old avatar deletion fails
|
||||
console.warn('Failed to delete old avatar:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Update user with new avatar key and provider
|
||||
const user = await ctx.prisma.user.update({
|
||||
// Return the updated user fields to match original API contract
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
profileImageKey: input.key,
|
||||
profileImageProvider: input.providerType,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
profileImageKey: true,
|
||||
@@ -93,23 +88,6 @@ export const avatarRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
detailsJson: {
|
||||
field: 'profileImageKey',
|
||||
newValue: input.key,
|
||||
provider: input.providerType,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return user
|
||||
}),
|
||||
|
||||
@@ -117,71 +95,17 @@ export const avatarRouter = router({
|
||||
* Get the current user's avatar URL
|
||||
*/
|
||||
getUrl: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.user.id
|
||||
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { profileImageKey: true, profileImageProvider: true },
|
||||
})
|
||||
|
||||
if (!user?.profileImageKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the provider that was used when the file was stored
|
||||
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
const url = await provider.getDownloadUrl(user.profileImageKey)
|
||||
|
||||
return url
|
||||
return getImageUrl(ctx.prisma, avatarConfig, ctx.user.id)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete the current user's avatar
|
||||
*/
|
||||
delete: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.user.id
|
||||
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { profileImageKey: true, profileImageProvider: true },
|
||||
return deleteImage(ctx.prisma, avatarConfig, ctx.user.id, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
if (!user?.profileImageKey) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Delete from the provider that was used when the file was stored
|
||||
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
try {
|
||||
await provider.deleteObject(user.profileImageKey)
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete avatar from storage:', error)
|
||||
}
|
||||
|
||||
// Update user - clear both key and provider
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
profileImageKey: null,
|
||||
profileImageProvider: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
detailsJson: { field: 'profileImageKey' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const evaluationRouter = router({
|
||||
/**
|
||||
@@ -213,21 +214,20 @@ export const evaluationRouter = router({
|
||||
])
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'EVALUATION_SUBMITTED',
|
||||
entityType: 'Evaluation',
|
||||
entityId: id,
|
||||
detailsJson: {
|
||||
projectId: evaluation.assignment.projectId,
|
||||
roundId: evaluation.assignment.roundId,
|
||||
globalScore: data.globalScore,
|
||||
binaryDecision: data.binaryDecision,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'EVALUATION_SUBMITTED',
|
||||
entityType: 'Evaluation',
|
||||
entityId: id,
|
||||
detailsJson: {
|
||||
projectId: evaluation.assignment.projectId,
|
||||
roundId: evaluation.assignment.roundId,
|
||||
globalScore: data.globalScore,
|
||||
binaryDecision: data.binaryDecision,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const exportRouter = router({
|
||||
/**
|
||||
@@ -69,15 +70,14 @@ export const exportRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'Evaluation',
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'Evaluation',
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -154,15 +154,14 @@ export const exportRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'ProjectScores',
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'EXPORT',
|
||||
entityType: 'ProjectScores',
|
||||
detailsJson: { roundId: input.roundId, count: data.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const fileRouter = router({
|
||||
/**
|
||||
@@ -55,16 +56,15 @@ export const fileRouter = router({
|
||||
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
|
||||
|
||||
// Log file access
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'FILE_DOWNLOADED',
|
||||
entityType: 'ProjectFile',
|
||||
detailsJson: { bucket: input.bucket, objectKey: input.objectKey },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
}).catch(() => {})
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'FILE_DOWNLOADED',
|
||||
entityType: 'ProjectFile',
|
||||
detailsJson: { bucket: input.bucket, objectKey: input.objectKey },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { url }
|
||||
}),
|
||||
@@ -112,20 +112,19 @@ export const fileRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPLOAD_FILE',
|
||||
entityType: 'ProjectFile',
|
||||
entityId: file.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
fileType: input.fileType,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPLOAD_FILE',
|
||||
entityType: 'ProjectFile',
|
||||
entityId: file.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
fileType: input.fileType,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -167,20 +166,19 @@ export const fileRouter = router({
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE_FILE',
|
||||
entityType: 'ProjectFile',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
fileName: file.fileName,
|
||||
bucket: file.bucket,
|
||||
objectKey: file.objectKey,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE_FILE',
|
||||
entityType: 'ProjectFile',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
fileName: file.fileName,
|
||||
bucket: file.bucket,
|
||||
objectKey: file.objectKey,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return file
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const gracePeriodRouter = router({
|
||||
/**
|
||||
@@ -24,21 +25,20 @@ export const gracePeriodRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'GRANT_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: gracePeriod.id,
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
userId: input.userId,
|
||||
projectId: input.projectId,
|
||||
extendedUntil: input.extendedUntil.toISOString(),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'GRANT_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: gracePeriod.id,
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
userId: input.userId,
|
||||
projectId: input.projectId,
|
||||
extendedUntil: input.extendedUntil.toISOString(),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return gracePeriod
|
||||
@@ -119,16 +119,15 @@ export const gracePeriodRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
@@ -145,19 +144,18 @@ export const gracePeriodRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'REVOKE_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
userId: gracePeriod.userId,
|
||||
roundId: gracePeriod.roundId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REVOKE_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
userId: gracePeriod.userId,
|
||||
roundId: gracePeriod.roundId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return gracePeriod
|
||||
@@ -188,19 +186,18 @@ export const gracePeriodRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_GRANT_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
userCount: input.userIds.length,
|
||||
created: created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_GRANT_GRACE_PERIOD',
|
||||
entityType: 'GracePeriod',
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
userCount: input.userIds.length,
|
||||
created: created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { created: created.count }
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
adminProcedure,
|
||||
} from '../trpc'
|
||||
import { getPresignedUrl } from '@/lib/minio'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
// Bucket for learning resources
|
||||
export const LEARNING_BUCKET = 'mopc-learning'
|
||||
@@ -312,16 +313,15 @@ export const learningResourceRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'LearningResource',
|
||||
entityId: resource.id,
|
||||
detailsJson: { title: input.title, resourceType: input.resourceType },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
@@ -359,16 +359,15 @@ export const learningResourceRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'LearningResource',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
@@ -385,16 +384,15 @@ export const learningResourceRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'LearningResource',
|
||||
entityId: input.id,
|
||||
detailsJson: { title: resource.title },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
@@ -480,15 +478,14 @@ export const learningResourceRouter = router({
|
||||
)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'REORDER',
|
||||
entityType: 'LearningResource',
|
||||
detailsJson: { count: input.items.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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 }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const liveVotingRouter = router({
|
||||
/**
|
||||
@@ -227,16 +228,15 @@ export const liveVotingRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'START_VOTING',
|
||||
entityType: 'LiveVotingSession',
|
||||
entityId: session.id,
|
||||
detailsJson: { projectId: input.projectId, durationSeconds: input.durationSeconds },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'START_VOTING',
|
||||
entityType: 'LiveVotingSession',
|
||||
entityId: session.id,
|
||||
detailsJson: { projectId: input.projectId, durationSeconds: input.durationSeconds },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return session
|
||||
@@ -273,16 +273,15 @@ export const liveVotingRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'END_SESSION',
|
||||
entityType: 'LiveVotingSession',
|
||||
entityId: session.id,
|
||||
detailsJson: {},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'END_SESSION',
|
||||
entityType: 'LiveVotingSession',
|
||||
entityId: session.id,
|
||||
detailsJson: {},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return session
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
|
||||
import {
|
||||
getStorageProviderWithType,
|
||||
createStorageProvider,
|
||||
generateLogoKey,
|
||||
getContentType,
|
||||
isValidImageType,
|
||||
type StorageProviderType,
|
||||
} from '@/lib/storage'
|
||||
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({
|
||||
/**
|
||||
@@ -23,11 +53,6 @@ export const logoRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate content type
|
||||
if (!isValidImageType(input.contentType)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP' })
|
||||
}
|
||||
|
||||
// Verify project exists
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
@@ -38,17 +63,12 @@ export const logoRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||
}
|
||||
|
||||
const key = generateLogoKey(input.projectId, input.fileName)
|
||||
const contentType = getContentType(input.fileName)
|
||||
|
||||
const { provider, providerType } = await getStorageProviderWithType()
|
||||
const uploadUrl = await provider.getUploadUrl(key, contentType)
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
key,
|
||||
providerType, // Return so client can pass it back on confirm
|
||||
}
|
||||
return getImageUploadUrl(
|
||||
input.projectId,
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
generateLogoKey
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -63,38 +83,22 @@ export const logoRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Use the provider that was used for upload
|
||||
const provider = createStorageProvider(input.providerType)
|
||||
const exists = await provider.objectExists(input.key)
|
||||
if (!exists) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' })
|
||||
}
|
||||
|
||||
// Delete old logo if exists (from its original provider)
|
||||
const currentProject = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
})
|
||||
|
||||
if (currentProject?.logoKey) {
|
||||
try {
|
||||
const oldProvider = createStorageProvider(
|
||||
(currentProject.logoProvider as StorageProviderType) || 's3'
|
||||
)
|
||||
await oldProvider.deleteObject(currentProject.logoKey)
|
||||
} catch (error) {
|
||||
// Log but don't fail if old logo deletion fails
|
||||
console.warn('Failed to delete old logo:', error)
|
||||
await confirmImageUpload(
|
||||
ctx.prisma,
|
||||
logoConfig,
|
||||
input.projectId,
|
||||
input.key,
|
||||
input.providerType,
|
||||
{
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update project with new logo key and provider
|
||||
const project = await ctx.prisma.project.update({
|
||||
// Return the updated project fields to match original API contract
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
data: {
|
||||
logoKey: input.key,
|
||||
logoProvider: input.providerType,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
logoKey: true,
|
||||
@@ -102,23 +106,6 @@ export const logoRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: {
|
||||
field: 'logoKey',
|
||||
newValue: input.key,
|
||||
provider: input.providerType,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return project
|
||||
}),
|
||||
|
||||
@@ -128,21 +115,7 @@ export const logoRouter = router({
|
||||
getUrl: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
})
|
||||
|
||||
if (!project?.logoKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the provider that was used when the file was stored
|
||||
const providerType = (project.logoProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
const url = await provider.getDownloadUrl(project.logoKey)
|
||||
|
||||
return url
|
||||
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -151,46 +124,10 @@ export const logoRouter = router({
|
||||
delete: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { logoKey: true, logoProvider: true },
|
||||
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
|
||||
userId: ctx.user.id,
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
if (!project?.logoKey) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Delete from the provider that was used when the file was stored
|
||||
const providerType = (project.logoProvider as StorageProviderType) || 's3'
|
||||
const provider = createStorageProvider(providerType)
|
||||
try {
|
||||
await provider.deleteObject(project.logoKey)
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete logo from storage:', error)
|
||||
}
|
||||
|
||||
// Update project - clear both key and provider
|
||||
await ctx.prisma.project.update({
|
||||
where: { id: input.projectId },
|
||||
data: {
|
||||
logoKey: null,
|
||||
logoProvider: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: { field: 'logoKey' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
notifyProjectTeam,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const mentorRouter = router({
|
||||
/**
|
||||
@@ -118,52 +119,54 @@ export const mentorRouter = router({
|
||||
where: { id: input.mentorId },
|
||||
})
|
||||
|
||||
// Create assignment
|
||||
const assignment = await ctx.prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
mentorId: input.mentorId,
|
||||
method: input.method,
|
||||
assignedBy: ctx.user.id,
|
||||
aiConfidenceScore: input.aiConfidenceScore,
|
||||
expertiseMatchScore: input.expertiseMatchScore,
|
||||
aiReasoning: input.aiReasoning,
|
||||
},
|
||||
include: {
|
||||
mentor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
expertiseTags: true,
|
||||
// Create assignment + audit log in transaction
|
||||
const assignment = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
mentorId: input.mentorId,
|
||||
method: input.method,
|
||||
assignedBy: ctx.user.id,
|
||||
aiConfidenceScore: input.aiConfidenceScore,
|
||||
expertiseMatchScore: input.expertiseMatchScore,
|
||||
aiReasoning: input.aiReasoning,
|
||||
},
|
||||
include: {
|
||||
mentor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
expertiseTags: true,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Create audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'MENTOR_ASSIGN',
|
||||
entityType: 'MentorAssignment',
|
||||
entityId: assignment.id,
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
projectTitle: assignment.project.title,
|
||||
projectTitle: created.project.title,
|
||||
mentorId: input.mentorId,
|
||||
mentorName: assignment.mentor.name,
|
||||
mentorName: created.mentor.name,
|
||||
method: input.method,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
// Get team lead info for mentor notification
|
||||
@@ -292,23 +295,22 @@ export const mentorRouter = router({
|
||||
})
|
||||
|
||||
// Create audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'MENTOR_AUTO_ASSIGN',
|
||||
entityType: 'MentorAssignment',
|
||||
entityId: assignment.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
projectTitle: assignment.project.title,
|
||||
mentorId,
|
||||
mentorName: assignment.mentor.name,
|
||||
method,
|
||||
aiConfidenceScore,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'MENTOR_AUTO_ASSIGN',
|
||||
entityType: 'MentorAssignment',
|
||||
entityId: assignment.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
projectTitle: assignment.project.title,
|
||||
mentorId,
|
||||
mentorName: assignment.mentor.name,
|
||||
method,
|
||||
aiConfidenceScore,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Get team lead info for mentor notification
|
||||
@@ -371,13 +373,10 @@ export const mentorRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.prisma.mentorAssignment.delete({
|
||||
where: { projectId: input.projectId },
|
||||
})
|
||||
|
||||
// Create audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
// Delete assignment + audit log in transaction
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'MENTOR_UNASSIGN',
|
||||
entityType: 'MentorAssignment',
|
||||
@@ -390,7 +389,11 @@ export const mentorRouter = router({
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.mentorAssignment.delete({
|
||||
where: { projectId: input.projectId },
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -518,20 +521,19 @@ export const mentorRouter = router({
|
||||
}
|
||||
|
||||
// Create audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'MENTOR_BULK_ASSIGN',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: {
|
||||
assigned,
|
||||
failed,
|
||||
useAI: input.useAI,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'MENTOR_BULK_ASSIGN',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: {
|
||||
assigned,
|
||||
failed,
|
||||
useAI: input.useAI,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -174,16 +175,15 @@ export const partnerRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Partner',
|
||||
entityId: partner.id,
|
||||
detailsJson: { name: input.name, partnerType: input.partnerType },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
@@ -218,16 +218,15 @@ export const partnerRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Partner',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
@@ -244,16 +243,15 @@ export const partnerRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Partner',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: partner.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
@@ -308,15 +306,14 @@ export const partnerRouter = router({
|
||||
)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'REORDER',
|
||||
entityType: 'Partner',
|
||||
detailsJson: { count: input.items.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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 }
|
||||
@@ -339,15 +336,14 @@ export const partnerRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE',
|
||||
entityType: 'Partner',
|
||||
detailsJson: { ids: input.ids, visibility: input.visibility },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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 }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const programRouter = router({
|
||||
/**
|
||||
@@ -70,16 +71,15 @@ export const programRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Program',
|
||||
entityId: program.id,
|
||||
detailsJson: input,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
@@ -106,16 +106,15 @@ export const programRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Program',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
@@ -133,16 +132,15 @@ export const programRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Program',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: program.name, year: program.year },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const projectRouter = router({
|
||||
/**
|
||||
@@ -297,25 +298,27 @@ export const projectRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { metadataJson, ...rest } = input
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
...rest,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
...rest,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
entityId: created.id,
|
||||
detailsJson: { title: input.title, roundId: input.roundId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return project
|
||||
@@ -457,16 +460,15 @@ export const projectRouter = router({
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Project',
|
||||
entityId: id,
|
||||
detailsJson: { ...data, status, metadataJson } as Prisma.InputJsonValue,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Project',
|
||||
entityId: id,
|
||||
detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return project
|
||||
@@ -478,21 +480,26 @@ export const projectRouter = router({
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const project = await ctx.prisma.project.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
const target = await tx.project.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Project',
|
||||
entityId: input.id,
|
||||
detailsJson: { title: project.title },
|
||||
detailsJson: { title: target.title },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return tx.project.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
})
|
||||
|
||||
return project
|
||||
@@ -559,15 +566,14 @@ export const projectRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'IMPORT',
|
||||
entityType: 'Project',
|
||||
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -617,40 +623,42 @@ export const projectRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Fetch matching projects BEFORE update so notifications match actually-updated records
|
||||
const [projects, round] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
roundId: input.roundId,
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
}),
|
||||
ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
|
||||
}),
|
||||
])
|
||||
|
||||
const matchingIds = projects.map((p) => p.id)
|
||||
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
id: { in: matchingIds },
|
||||
roundId: input.roundId,
|
||||
},
|
||||
data: { status: input.status },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE_STATUS',
|
||||
entityType: 'Project',
|
||||
detailsJson: { ids: input.ids, roundId: input.roundId, status: input.status },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE_STATUS',
|
||||
entityType: 'Project',
|
||||
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: updated.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Get round details including configured notification type
|
||||
const [projects, round] = await Promise.all([
|
||||
input.ids.length > 0
|
||||
? ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.ids } },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
|
||||
}),
|
||||
])
|
||||
|
||||
// Helper to get notification title based on type
|
||||
const getNotificationTitle = (type: string): string => {
|
||||
const titles: Record<string, string> = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
notifyRoundJury,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
export const roundRouter = router({
|
||||
/**
|
||||
@@ -114,40 +115,43 @@ export const roundRouter = router({
|
||||
const now = new Date()
|
||||
const shouldAutoActivate = input.votingStartAt && input.votingStartAt <= now
|
||||
|
||||
const round = await ctx.prisma.round.create({
|
||||
data: {
|
||||
...rest,
|
||||
sortOrder,
|
||||
status: shouldAutoActivate ? 'ACTIVE' : 'DRAFT',
|
||||
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// For FILTERING rounds, automatically move all projects from the program to this round
|
||||
if (input.roundType === 'FILTERING') {
|
||||
await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
round: { programId: input.programId },
|
||||
roundId: { not: round.id },
|
||||
},
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.round.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
status: 'SUBMITTED',
|
||||
...rest,
|
||||
sortOrder,
|
||||
status: shouldAutoActivate ? 'ACTIVE' : 'DRAFT',
|
||||
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
// For FILTERING rounds, automatically move all projects from the program to this round
|
||||
if (input.roundType === 'FILTERING') {
|
||||
await tx.project.updateMany({
|
||||
where: {
|
||||
round: { programId: input.programId },
|
||||
roundId: { not: created.id },
|
||||
},
|
||||
data: {
|
||||
roundId: created.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Round',
|
||||
entityId: round.id,
|
||||
detailsJson: { ...rest, settingsJson } as Prisma.InputJsonValue,
|
||||
entityId: created.id,
|
||||
detailsJson: { ...rest, settingsJson } as Record<string, unknown>,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return round
|
||||
@@ -215,26 +219,28 @@ export const roundRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const round = await ctx.prisma.round.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(autoActivate && { status: 'ACTIVE' }),
|
||||
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
})
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.round.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(autoActivate && { status: 'ACTIVE' }),
|
||||
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Round',
|
||||
entityId: id,
|
||||
detailsJson: { ...data, settingsJson } as Prisma.InputJsonValue,
|
||||
detailsJson: { ...data, settingsJson } as Record<string, unknown>,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return round
|
||||
@@ -275,11 +281,6 @@ export const roundRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const round = await ctx.prisma.round.update({
|
||||
where: { id: input.id },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
// Map status to specific action name
|
||||
const statusActionMap: Record<string, string> = {
|
||||
ACTIVE: 'ROUND_ACTIVATED',
|
||||
@@ -288,9 +289,14 @@ export const roundRouter = router({
|
||||
}
|
||||
const action = statusActionMap[input.status] || 'UPDATE_STATUS'
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.round.update({
|
||||
where: { id: input.id },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action,
|
||||
entityType: 'Round',
|
||||
@@ -306,7 +312,9 @@ export const roundRouter = router({
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
// Notify jury members when round is activated
|
||||
@@ -485,16 +493,15 @@ export const roundRouter = router({
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_EVALUATION_FORM',
|
||||
entityType: 'EvaluationForm',
|
||||
entityId: form.id,
|
||||
detailsJson: { roundId, criteriaCount: criteria.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_EVALUATION_FORM',
|
||||
entityType: 'EvaluationForm',
|
||||
entityId: form.id,
|
||||
detailsJson: { roundId, criteriaCount: criteria.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return form
|
||||
@@ -525,13 +532,9 @@ export const roundRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
await ctx.prisma.round.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Round',
|
||||
@@ -544,7 +547,11 @@ export const roundRouter = router({
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.round.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
})
|
||||
|
||||
return round
|
||||
@@ -601,16 +608,15 @@ export const roundRouter = router({
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'ASSIGN_PROJECTS_TO_ROUND',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { projectCount: updated.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'ASSIGN_PROJECTS_TO_ROUND',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { projectCount: updated.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { assigned: updated.count }
|
||||
@@ -640,16 +646,15 @@ export const roundRouter = router({
|
||||
const deleted = { count: updated.count }
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'REMOVE_PROJECTS_FROM_ROUND',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { projectCount: deleted.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REMOVE_PROJECTS_FROM_ROUND',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { projectCount: deleted.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { removed: deleted.count }
|
||||
@@ -711,20 +716,19 @@ export const roundRouter = router({
|
||||
const created = { count: updated.count }
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'ADVANCE_PROJECTS',
|
||||
entityType: 'Round',
|
||||
entityId: input.toRoundId,
|
||||
detailsJson: {
|
||||
fromRoundId: input.fromRoundId,
|
||||
toRoundId: input.toRoundId,
|
||||
projectCount: created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'ADVANCE_PROJECTS',
|
||||
entityType: 'Round',
|
||||
entityId: input.toRoundId,
|
||||
detailsJson: {
|
||||
fromRoundId: input.fromRoundId,
|
||||
toRoundId: input.toRoundId,
|
||||
projectCount: created.count,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { advanced: created.count }
|
||||
@@ -752,16 +756,15 @@ export const roundRouter = router({
|
||||
)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'REORDER_ROUNDS',
|
||||
entityType: 'Program',
|
||||
entityId: input.programId,
|
||||
detailsJson: { roundIds: input.roundIds },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REORDER_ROUNDS',
|
||||
entityType: 'Program',
|
||||
entityId: input.programId,
|
||||
detailsJson: { roundIds: input.roundIds },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
|
||||
import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai'
|
||||
import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage'
|
||||
import { clearStorageProviderCache } from '@/lib/storage'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
/**
|
||||
* Categorize an OpenAI model for display
|
||||
@@ -124,19 +125,18 @@ export const settingsRouter = router({
|
||||
}
|
||||
|
||||
// Audit log (don't log actual value for secrets)
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_SETTING',
|
||||
entityType: 'SystemSettings',
|
||||
entityId: setting.id,
|
||||
detailsJson: {
|
||||
key: input.key,
|
||||
isSecret: setting.isSecret,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_SETTING',
|
||||
entityType: 'SystemSettings',
|
||||
entityId: setting.id,
|
||||
detailsJson: {
|
||||
key: input.key,
|
||||
isSecret: setting.isSecret,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return setting
|
||||
@@ -193,15 +193,14 @@ export const settingsRouter = router({
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_SETTINGS_BATCH',
|
||||
entityType: 'SystemSettings',
|
||||
detailsJson: { keys: input.settings.map((s) => s.key) },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_SETTINGS_BATCH',
|
||||
entityType: 'SystemSettings',
|
||||
detailsJson: { keys: input.settings.map((s) => s.key) },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -357,19 +356,18 @@ export const settingsRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_NOTIFICATION_PREFERENCES',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: {
|
||||
notificationPreference: input.notificationPreference,
|
||||
whatsappOptIn: input.whatsappOptIn,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_NOTIFICATION_PREFERENCES',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: {
|
||||
notificationPreference: input.notificationPreference,
|
||||
whatsappOptIn: input.whatsappOptIn,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return user
|
||||
|
||||
@@ -97,26 +97,32 @@ export const specialAwardRouter = router({
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
|
||||
const award = await ctx.prisma.specialAward.create({
|
||||
data: {
|
||||
programId: input.programId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
criteriaText: input.criteriaText,
|
||||
useAiEligibility: input.useAiEligibility ?? true,
|
||||
scoringMode: input.scoringMode,
|
||||
maxRankedPicks: input.maxRankedPicks,
|
||||
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
|
||||
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
||||
},
|
||||
})
|
||||
const award = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.specialAward.create({
|
||||
data: {
|
||||
programId: input.programId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
criteriaText: input.criteriaText,
|
||||
useAiEligibility: input.useAiEligibility ?? true,
|
||||
scoringMode: input.scoringMode,
|
||||
maxRankedPicks: input.maxRankedPicks,
|
||||
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
|
||||
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: award.id,
|
||||
detailsJson: { name: input.name, scoringMode: input.scoringMode },
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, scoringMode: input.scoringMode } as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return award
|
||||
@@ -166,13 +172,17 @@ export const specialAwardRouter = router({
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.specialAward.delete({ where: { id: input.id } })
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
await tx.specialAward.delete({ where: { id: input.id } })
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -216,25 +226,31 @@ export const specialAwardRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const award = await ctx.prisma.specialAward.update({
|
||||
where: { id: input.id },
|
||||
data: updateData,
|
||||
})
|
||||
const award = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.specialAward.update({
|
||||
where: { id: input.id },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_STATUS',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
previousStatus: current.status,
|
||||
newStatus: input.status,
|
||||
...(votingStartAtUpdated && {
|
||||
votingStartAtUpdated: true,
|
||||
previousVotingStartAt: current.votingStartAt,
|
||||
newVotingStartAt: now,
|
||||
}),
|
||||
},
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_STATUS',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
previousStatus: current.status,
|
||||
newStatus: input.status,
|
||||
...(votingStartAtUpdated && {
|
||||
votingStartAtUpdated: true,
|
||||
previousVotingStartAt: current.votingStartAt,
|
||||
newVotingStartAt: now,
|
||||
}),
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return award
|
||||
@@ -780,26 +796,32 @@ export const specialAwardRouter = router({
|
||||
select: { winnerProjectId: true },
|
||||
})
|
||||
|
||||
const award = await ctx.prisma.specialAward.update({
|
||||
where: { id: input.awardId },
|
||||
data: {
|
||||
winnerProjectId: input.projectId,
|
||||
winnerOverridden: input.overridden,
|
||||
winnerOverriddenBy: input.overridden ? ctx.user.id : null,
|
||||
},
|
||||
})
|
||||
const award = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.specialAward.update({
|
||||
where: { id: input.awardId },
|
||||
data: {
|
||||
winnerProjectId: input.projectId,
|
||||
winnerOverridden: input.overridden,
|
||||
winnerOverriddenBy: input.overridden ? ctx.user.id : null,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: {
|
||||
action: 'SET_AWARD_WINNER',
|
||||
previousWinner: previous.winnerProjectId,
|
||||
newWinner: input.projectId,
|
||||
overridden: input.overridden,
|
||||
},
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: {
|
||||
action: 'SET_AWARD_WINNER',
|
||||
previousWinner: previous.winnerProjectId,
|
||||
newWinner: input.projectId,
|
||||
overridden: input.overridden,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return award
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import {
|
||||
tagProject,
|
||||
getTagSuggestions,
|
||||
@@ -299,16 +300,15 @@ export const tagRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'ExpertiseTag',
|
||||
entityId: tag.id,
|
||||
detailsJson: { name: input.name, category: input.category },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'ExpertiseTag',
|
||||
entityId: tag.id,
|
||||
detailsJson: { name: input.name, category: input.category },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return tag
|
||||
@@ -399,16 +399,15 @@ export const tagRouter = router({
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'ExpertiseTag',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'ExpertiseTag',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return tag
|
||||
@@ -460,16 +459,15 @@ export const tagRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'ExpertiseTag',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: tag.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'ExpertiseTag',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: tag.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return tag
|
||||
@@ -520,15 +518,14 @@ export const tagRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_CREATE',
|
||||
entityType: 'ExpertiseTag',
|
||||
detailsJson: { count: created.count, skipped: existingNames.size },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_CREATE',
|
||||
entityType: 'ExpertiseTag',
|
||||
detailsJson: { count: created.count, skipped: existingNames.size },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { created: created.count, skipped: existingNames.size }
|
||||
@@ -608,19 +605,18 @@ export const tagRouter = router({
|
||||
const result = await tagProject(input.projectId, ctx.user.id)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'AI_TAG',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: {
|
||||
applied: result.applied.map((t) => t.tagName),
|
||||
tokensUsed: result.tokensUsed,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'AI_TAG',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: {
|
||||
applied: result.applied.map((t) => t.tagName),
|
||||
tokensUsed: result.tokensUsed,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -669,16 +665,15 @@ export const tagRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'START_AI_TAG_JOB',
|
||||
entityType: input.programId ? 'Program' : 'Round',
|
||||
entityId: input.programId || input.roundId!,
|
||||
detailsJson: { jobId: job.id },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'START_AI_TAG_JOB',
|
||||
entityType: input.programId ? 'Program' : 'Round',
|
||||
entityId: input.programId || input.roundId!,
|
||||
detailsJson: { jobId: job.id },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Start job in background (don't await)
|
||||
@@ -774,16 +769,15 @@ export const tagRouter = router({
|
||||
await addProjectTag(input.projectId, input.tagId)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'ADD_TAG',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: { tagId: input.tagId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'ADD_TAG',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: { tagId: input.tagId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -803,16 +797,15 @@ export const tagRouter = router({
|
||||
await removeProjectTag(input.projectId, input.tagId)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'REMOVE_TAG',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: { tagId: input.tagId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REMOVE_TAG',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: { tagId: input.tagId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
|
||||
@@ -6,6 +6,7 @@ import { router, protectedProcedure, adminProcedure, superAdminProcedure, public
|
||||
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
|
||||
import { hashPassword, validatePassword } from '@/lib/password'
|
||||
import { attachAvatarUrls } from '@/server/utils/avatar-url'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
@@ -146,9 +147,10 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log before deletion
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
// Wrap audit + deletion in a transaction
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE_OWN_ACCOUNT',
|
||||
entityType: 'User',
|
||||
@@ -156,12 +158,11 @@ export const userRouter = router({
|
||||
detailsJson: { email: user.email },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Delete the user
|
||||
await ctx.prisma.user.delete({
|
||||
where: { id: ctx.user.id },
|
||||
await tx.user.delete({
|
||||
where: { id: ctx.user.id },
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -288,24 +289,26 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const user = await ctx.prisma.user.create({
|
||||
data: {
|
||||
...input,
|
||||
status: 'INVITED',
|
||||
},
|
||||
})
|
||||
const user = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
...input,
|
||||
status: 'INVITED',
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
entityId: created.id,
|
||||
detailsJson: { email: input.email, role: input.role },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return user
|
||||
@@ -348,14 +351,14 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const user = await ctx.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
const user = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'User',
|
||||
@@ -363,13 +366,12 @@ export const userRouter = router({
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Track role change specifically
|
||||
if (data.role && data.role !== targetUser.role) {
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
// Track role change specifically
|
||||
if (data.role && data.role !== targetUser.role) {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'ROLE_CHANGED',
|
||||
entityType: 'User',
|
||||
@@ -377,9 +379,11 @@ export const userRouter = router({
|
||||
detailsJson: { previousRole: targetUser.role, newRole: data.role },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return user
|
||||
}),
|
||||
@@ -398,21 +402,27 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const user = await ctx.prisma.user.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
const user = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Fetch user data before deletion for the audit log
|
||||
const target = await tx.user.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: { email: true },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'User',
|
||||
entityId: input.id,
|
||||
detailsJson: { email: user.email },
|
||||
detailsJson: { email: target.email },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return tx.user.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
})
|
||||
|
||||
return user
|
||||
@@ -490,15 +500,14 @@ export const userRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_CREATE',
|
||||
entityType: 'User',
|
||||
detailsJson: { count: created.count, skipped, duplicatesInInput },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_CREATE',
|
||||
entityType: 'User',
|
||||
detailsJson: { count: created.count, skipped, duplicatesInInput },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Auto-send invitation emails to newly created users
|
||||
@@ -534,15 +543,14 @@ export const userRouter = router({
|
||||
|
||||
// Audit log for assignments if any were created
|
||||
if (assignmentsCreated > 0) {
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_ASSIGN',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_ASSIGN',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -692,16 +700,15 @@ export const userRouter = router({
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'SEND_INVITATION',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: user.email },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'SEND_INVITATION',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: user.email },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true, email: user.email }
|
||||
@@ -770,15 +777,14 @@ export const userRouter = router({
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_SEND_INVITATIONS',
|
||||
entityType: 'User',
|
||||
detailsJson: { sent, errors },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_SEND_INVITATIONS',
|
||||
entityType: 'User',
|
||||
detailsJson: { sent, errors },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { sent, skipped: input.userIds.length - users.length, errors }
|
||||
@@ -810,23 +816,23 @@ export const userRouter = router({
|
||||
const userTags = input.expertiseTags || []
|
||||
const mergedTags = [...new Set([...adminTags, ...userTags])]
|
||||
|
||||
const user = await ctx.prisma.user.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
name: input.name,
|
||||
phoneNumber: input.phoneNumber,
|
||||
country: input.country,
|
||||
bio: input.bio,
|
||||
expertiseTags: mergedTags,
|
||||
notificationPreference: input.notificationPreference || 'EMAIL',
|
||||
onboardingCompletedAt: new Date(),
|
||||
status: 'ACTIVE', // Activate user after onboarding
|
||||
},
|
||||
})
|
||||
const user = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.user.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
name: input.name,
|
||||
phoneNumber: input.phoneNumber,
|
||||
country: input.country,
|
||||
bio: input.bio,
|
||||
expertiseTags: mergedTags,
|
||||
notificationPreference: input.notificationPreference || 'EMAIL',
|
||||
onboardingCompletedAt: new Date(),
|
||||
status: 'ACTIVE', // Activate user after onboarding
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'COMPLETE_ONBOARDING',
|
||||
entityType: 'User',
|
||||
@@ -834,7 +840,9 @@ export const userRouter = router({
|
||||
detailsJson: { name: input.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return user
|
||||
@@ -901,19 +909,19 @@ export const userRouter = router({
|
||||
// Hash the password
|
||||
const passwordHash = await hashPassword(input.password)
|
||||
|
||||
// Update user with new password
|
||||
const user = await ctx.prisma.user.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordSetAt: new Date(),
|
||||
mustSetPassword: false,
|
||||
},
|
||||
})
|
||||
// Update user with new password + audit in transaction
|
||||
const user = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.user.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordSetAt: new Date(),
|
||||
mustSetPassword: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'PASSWORD_SET',
|
||||
entityType: 'User',
|
||||
@@ -921,7 +929,9 @@ export const userRouter = router({
|
||||
detailsJson: { timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
return { success: true, email: user.email }
|
||||
@@ -982,18 +992,18 @@ export const userRouter = router({
|
||||
// Hash the new password
|
||||
const passwordHash = await hashPassword(input.newPassword)
|
||||
|
||||
// Update user with new password
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordSetAt: new Date(),
|
||||
},
|
||||
})
|
||||
// Update user with new password + audit in transaction
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordSetAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'PASSWORD_CHANGED',
|
||||
entityType: 'User',
|
||||
@@ -1001,7 +1011,7 @@ export const userRouter = router({
|
||||
detailsJson: { timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -1040,16 +1050,15 @@ export const userRouter = router({
|
||||
// The actual email is sent through NextAuth's email provider
|
||||
|
||||
// Audit log (without user ID since this is public)
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: null, // No authenticated user
|
||||
action: 'REQUEST_PASSWORD_RESET',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: null, // No authenticated user
|
||||
action: 'REQUEST_PASSWORD_RESET',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
|
||||
|
||||
@@ -24,7 +24,6 @@ export interface ScoreBreakdown {
|
||||
bioMatch: number
|
||||
workloadBalance: number
|
||||
countryMatch: number
|
||||
aiBoost: number
|
||||
}
|
||||
|
||||
export interface AssignmentScore {
|
||||
@@ -367,7 +366,6 @@ export async function getSmartSuggestions(options: {
|
||||
bioMatch: bioScore,
|
||||
workloadBalance: workloadScore,
|
||||
countryMatch: countryScore,
|
||||
aiBoost: 0,
|
||||
},
|
||||
reasoning,
|
||||
matchingTags,
|
||||
@@ -490,7 +488,6 @@ export async function getMentorSuggestionsForProject(
|
||||
bioMatch: bioScore,
|
||||
workloadBalance: workloadScore,
|
||||
countryMatch: countryScore,
|
||||
aiBoost: 0,
|
||||
},
|
||||
reasoning,
|
||||
matchingTags,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma as globalPrisma } from '@/lib/prisma'
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
|
||||
/** Minimal Prisma-like client that supports auditLog.create (works with PrismaClient and transaction clients). */
|
||||
type AuditPrismaClient = Pick<PrismaClient, 'auditLog'>
|
||||
|
||||
/**
|
||||
* Shared utility for creating audit log entries.
|
||||
* Wrapped in try-catch so audit failures never break the calling operation.
|
||||
*
|
||||
* @param input.prisma - Optional Prisma client instance. When omitted the global
|
||||
* singleton is used. Pass `ctx.prisma` from tRPC handlers so audit writes
|
||||
* participate in the same transaction when applicable.
|
||||
*/
|
||||
export async function logAudit(input: {
|
||||
prisma?: AuditPrismaClient
|
||||
userId?: string | null
|
||||
action: string
|
||||
entityType: string
|
||||
@@ -15,7 +23,8 @@ export async function logAudit(input: {
|
||||
userAgent?: string
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
const db = input.prisma ?? globalPrisma
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: input.userId ?? null,
|
||||
action: input.action,
|
||||
|
||||
212
src/server/utils/image-upload.ts
Normal file
212
src/server/utils/image-upload.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import { logAudit } from './audit'
|
||||
import {
|
||||
getStorageProviderWithType,
|
||||
createStorageProvider,
|
||||
getContentType,
|
||||
isValidImageType,
|
||||
type StorageProviderType,
|
||||
} from '@/lib/storage'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configuration for an image upload domain (avatar, logo, etc.)
|
||||
*
|
||||
* Each config describes how to read/write image keys for a specific entity.
|
||||
*/
|
||||
export type ImageUploadConfig<TSelectResult> = {
|
||||
/** Human-readable label used in log/error messages (e.g. "avatar", "logo") */
|
||||
label: string
|
||||
|
||||
/** Generate a storage object key for a new upload */
|
||||
generateKey: (entityId: string, fileName: string) => string
|
||||
|
||||
/** Prisma select – fetch the current image key + provider for the entity */
|
||||
findCurrent: (
|
||||
prisma: PrismaClient,
|
||||
entityId: string
|
||||
) => Promise<TSelectResult | null>
|
||||
|
||||
/** Extract the image key from the select result */
|
||||
getImageKey: (record: TSelectResult) => string | null
|
||||
|
||||
/** Extract the storage provider type from the select result */
|
||||
getProviderType: (record: TSelectResult) => StorageProviderType
|
||||
|
||||
/** Prisma update – set the new image key + provider on the entity */
|
||||
setImage: (
|
||||
prisma: PrismaClient,
|
||||
entityId: string,
|
||||
key: string,
|
||||
providerType: StorageProviderType
|
||||
) => Promise<unknown>
|
||||
|
||||
/** Prisma update – clear the image key + provider on the entity */
|
||||
clearImage: (prisma: PrismaClient, entityId: string) => Promise<unknown>
|
||||
|
||||
/** Audit log entity type (e.g. "User", "Project") */
|
||||
auditEntityType: string
|
||||
|
||||
/** Audit log field name (e.g. "profileImageKey", "logoKey") */
|
||||
auditFieldName: string
|
||||
}
|
||||
|
||||
type AuditContext = {
|
||||
userId: string
|
||||
ip: string
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a pre-signed upload URL for an image.
|
||||
*
|
||||
* Validates the content type, generates a storage key, and returns the
|
||||
* upload URL along with the key and provider type.
|
||||
*/
|
||||
export async function getImageUploadUrl(
|
||||
entityId: string,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
generateKey: (entityId: string, fileName: string) => string
|
||||
): Promise<{ uploadUrl: string; key: string; providerType: StorageProviderType }> {
|
||||
if (!isValidImageType(contentType)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
|
||||
})
|
||||
}
|
||||
|
||||
const key = generateKey(entityId, fileName)
|
||||
const resolvedContentType = getContentType(fileName)
|
||||
|
||||
const { provider, providerType } = await getStorageProviderWithType()
|
||||
const uploadUrl = await provider.getUploadUrl(key, resolvedContentType)
|
||||
|
||||
return { uploadUrl, key, providerType }
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm an image upload: verify the object exists in storage, delete the
|
||||
* previous image (if any), persist the new key, and write an audit log entry.
|
||||
*/
|
||||
export async function confirmImageUpload<TSelectResult>(
|
||||
prisma: PrismaClient,
|
||||
config: ImageUploadConfig<TSelectResult>,
|
||||
entityId: string,
|
||||
key: string,
|
||||
providerType: StorageProviderType,
|
||||
audit: AuditContext
|
||||
): Promise<void> {
|
||||
// 1. Verify upload exists in storage
|
||||
const provider = createStorageProvider(providerType)
|
||||
const exists = await provider.objectExists(key)
|
||||
if (!exists) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Upload not found. Please try uploading again.',
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Delete old image if present
|
||||
const current = await config.findCurrent(prisma, entityId)
|
||||
if (current) {
|
||||
const oldKey = config.getImageKey(current)
|
||||
if (oldKey) {
|
||||
try {
|
||||
const oldProvider = createStorageProvider(config.getProviderType(current))
|
||||
await oldProvider.deleteObject(oldKey)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete old ${config.label}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Persist new image key + provider
|
||||
await config.setImage(prisma, entityId, key, providerType)
|
||||
|
||||
// 4. Audit log
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: audit.userId,
|
||||
action: 'UPDATE',
|
||||
entityType: config.auditEntityType,
|
||||
entityId,
|
||||
detailsJson: {
|
||||
field: config.auditFieldName,
|
||||
newValue: key,
|
||||
provider: providerType,
|
||||
},
|
||||
ipAddress: audit.ip,
|
||||
userAgent: audit.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the download URL for an existing image, or null if none is set.
|
||||
*/
|
||||
export async function getImageUrl<TSelectResult>(
|
||||
prisma: PrismaClient,
|
||||
config: Pick<ImageUploadConfig<TSelectResult>, 'findCurrent' | 'getImageKey' | 'getProviderType'>,
|
||||
entityId: string
|
||||
): Promise<string | null> {
|
||||
const record = await config.findCurrent(prisma, entityId)
|
||||
if (!record) return null
|
||||
|
||||
const imageKey = config.getImageKey(record)
|
||||
if (!imageKey) return null
|
||||
|
||||
const providerType = config.getProviderType(record)
|
||||
const provider = createStorageProvider(providerType)
|
||||
return provider.getDownloadUrl(imageKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image from storage and clear the reference in the database.
|
||||
* Writes an audit log entry.
|
||||
*/
|
||||
export async function deleteImage<TSelectResult>(
|
||||
prisma: PrismaClient,
|
||||
config: ImageUploadConfig<TSelectResult>,
|
||||
entityId: string,
|
||||
audit: AuditContext
|
||||
): Promise<{ success: true }> {
|
||||
const record = await config.findCurrent(prisma, entityId)
|
||||
if (!record) return { success: true }
|
||||
|
||||
const imageKey = config.getImageKey(record)
|
||||
if (!imageKey) return { success: true }
|
||||
|
||||
// Delete from storage
|
||||
const providerType = config.getProviderType(record)
|
||||
const provider = createStorageProvider(providerType)
|
||||
try {
|
||||
await provider.deleteObject(imageKey)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete ${config.label} from storage:`, error)
|
||||
}
|
||||
|
||||
// Clear in database
|
||||
await config.clearImage(prisma, entityId)
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: audit.userId,
|
||||
action: 'DELETE',
|
||||
entityType: config.auditEntityType,
|
||||
entityId,
|
||||
detailsJson: { field: config.auditFieldName },
|
||||
ipAddress: audit.ip,
|
||||
userAgent: audit.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
Reference in New Issue
Block a user