feat: admin UX improvements — notify buttons, eval config, round finalization
Custom body support for advancement/rejection notification emails, evaluation config toggle fix, user actions improvements, round finalization with reorder support, project detail page enhancements, award pool duplicate prevention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,10 +9,11 @@ import { createBulkNotifications } from '../services/in-app-notification'
|
||||
import {
|
||||
getAdvancementNotificationTemplate,
|
||||
getRejectionNotificationTemplate,
|
||||
sendStyledNotificationEmail,
|
||||
sendInvitationEmail,
|
||||
getBaseUrl,
|
||||
} from '@/lib/email'
|
||||
import { sendBatchNotifications } from '../services/notification-sender'
|
||||
import type { NotificationItem } from '../services/notification-sender'
|
||||
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
|
||||
import {
|
||||
openWindow,
|
||||
@@ -812,10 +813,11 @@ export const roundRouter = router({
|
||||
roundId: z.string(),
|
||||
targetRoundId: z.string().optional(),
|
||||
customMessage: z.string().optional(),
|
||||
fullCustomBody: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { roundId, targetRoundId, customMessage } = input
|
||||
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
|
||||
|
||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
@@ -865,7 +867,9 @@ export const roundRouter = router({
|
||||
'Your Project',
|
||||
currentRound.name,
|
||||
toRoundName,
|
||||
customMessage || undefined
|
||||
customMessage || undefined,
|
||||
undefined,
|
||||
fullCustomBody,
|
||||
)
|
||||
|
||||
return { html: template.html, subject: template.subject, recipientCount }
|
||||
@@ -877,11 +881,12 @@ export const roundRouter = router({
|
||||
roundId: z.string(),
|
||||
targetRoundId: z.string().optional(),
|
||||
customMessage: z.string().optional(),
|
||||
fullCustomBody: z.boolean().default(false),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, targetRoundId, customMessage } = input
|
||||
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
|
||||
|
||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
@@ -922,48 +927,47 @@ export const roundRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
const allUserIds = new Set<string>()
|
||||
const items: NotificationItem[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
const recipients = new Map<string, string | null>()
|
||||
const recipients = new Map<string, { name: string | null; userId: string }>()
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user.email) {
|
||||
recipients.set(tm.user.email, tm.user.name)
|
||||
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
|
||||
allUserIds.add(tm.user.id)
|
||||
}
|
||||
}
|
||||
if (recipients.size === 0 && project.submittedByEmail) {
|
||||
recipients.set(project.submittedByEmail, null)
|
||||
recipients.set(project.submittedByEmail, { name: null, userId: '' })
|
||||
}
|
||||
|
||||
for (const [email, name] of recipients) {
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
email,
|
||||
name || '',
|
||||
'ADVANCEMENT_NOTIFICATION',
|
||||
{
|
||||
title: 'Your project has advanced!',
|
||||
message: '',
|
||||
linkUrl: '/applicant',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
fromRoundName: currentRound.name,
|
||||
toRoundName,
|
||||
customMessage: customMessage || undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
sent++
|
||||
} catch (err) {
|
||||
console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err)
|
||||
failed++
|
||||
}
|
||||
for (const [email, { name, userId }] of recipients) {
|
||||
items.push({
|
||||
email,
|
||||
name: name || '',
|
||||
type: 'ADVANCEMENT_NOTIFICATION',
|
||||
context: {
|
||||
title: 'Your project has advanced!',
|
||||
message: '',
|
||||
linkUrl: '/applicant',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
fromRoundName: currentRound.name,
|
||||
toRoundName,
|
||||
customMessage: customMessage || undefined,
|
||||
fullCustomBody,
|
||||
},
|
||||
},
|
||||
projectId: project.id,
|
||||
userId: userId || undefined,
|
||||
roundId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result = await sendBatchNotifications(items)
|
||||
|
||||
// Create in-app notifications
|
||||
if (allUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
@@ -985,12 +989,12 @@ export const roundRouter = router({
|
||||
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
|
||||
entityType: 'Round',
|
||||
entityId: roundId,
|
||||
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
|
||||
detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { sent, failed }
|
||||
return { sent: result.sent, failed: result.failed }
|
||||
}),
|
||||
|
||||
previewRejectionEmail: adminProcedure
|
||||
@@ -998,22 +1002,36 @@ export const roundRouter = router({
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
customMessage: z.string().optional(),
|
||||
fullCustomBody: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { roundId, customMessage } = input
|
||||
const { roundId, customMessage, fullCustomBody } = input
|
||||
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { name: true },
|
||||
select: { name: true, roundType: true },
|
||||
})
|
||||
|
||||
// Count recipients: team members of REJECTED projects
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'REJECTED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||
// For FILTERING rounds, also count projects filtered out via FilteringResult
|
||||
let projectIds: string[]
|
||||
if (round.roundType === 'FILTERING') {
|
||||
const fromPRS = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'REJECTED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const fromFR = await ctx.prisma.filteringResult.findMany({
|
||||
where: { roundId, finalOutcome: 'FILTERED_OUT' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))]
|
||||
} else {
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'REJECTED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
projectIds = projectStates.map((ps) => ps.projectId)
|
||||
}
|
||||
|
||||
let recipientCount = 0
|
||||
if (projectIds.length > 0) {
|
||||
@@ -1039,7 +1057,8 @@ export const roundRouter = router({
|
||||
'Team Member',
|
||||
'Your Project',
|
||||
round.name,
|
||||
customMessage || undefined
|
||||
customMessage || undefined,
|
||||
fullCustomBody,
|
||||
)
|
||||
|
||||
return { html: template.html, subject: template.subject, recipientCount }
|
||||
@@ -1050,21 +1069,36 @@ export const roundRouter = router({
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
customMessage: z.string().optional(),
|
||||
fullCustomBody: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, customMessage } = input
|
||||
const { roundId, customMessage, fullCustomBody } = input
|
||||
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { name: true },
|
||||
select: { name: true, roundType: true },
|
||||
})
|
||||
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'REJECTED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||
// For FILTERING rounds, also include projects filtered out via FilteringResult
|
||||
let projectIds: string[]
|
||||
if (round.roundType === 'FILTERING') {
|
||||
const fromPRS = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'REJECTED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const fromFR = await ctx.prisma.filteringResult.findMany({
|
||||
where: { roundId, finalOutcome: 'FILTERED_OUT' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))]
|
||||
} else {
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'REJECTED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
projectIds = projectStates.map((ps) => ps.projectId)
|
||||
}
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return { sent: 0, failed: 0 }
|
||||
@@ -1082,47 +1116,46 @@ export const roundRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
const allUserIds = new Set<string>()
|
||||
const items: NotificationItem[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
const recipients = new Map<string, string | null>()
|
||||
const recipients = new Map<string, { name: string | null; userId: string }>()
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user.email) {
|
||||
recipients.set(tm.user.email, tm.user.name)
|
||||
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
|
||||
allUserIds.add(tm.user.id)
|
||||
}
|
||||
}
|
||||
if (recipients.size === 0 && project.submittedByEmail) {
|
||||
recipients.set(project.submittedByEmail, null)
|
||||
recipients.set(project.submittedByEmail, { name: null, userId: '' })
|
||||
}
|
||||
|
||||
for (const [email, name] of recipients) {
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
email,
|
||||
name || '',
|
||||
'REJECTION_NOTIFICATION',
|
||||
{
|
||||
title: 'Update on your application',
|
||||
message: '',
|
||||
linkUrl: '/applicant',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
roundName: round.name,
|
||||
customMessage: customMessage || undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
sent++
|
||||
} catch (err) {
|
||||
console.error(`[sendRejectionNotifications] Failed for ${email}:`, err)
|
||||
failed++
|
||||
}
|
||||
for (const [email, { name, userId }] of recipients) {
|
||||
items.push({
|
||||
email,
|
||||
name: name || '',
|
||||
type: 'REJECTION_NOTIFICATION',
|
||||
context: {
|
||||
title: 'Update on your application',
|
||||
message: '',
|
||||
linkUrl: '/applicant',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
roundName: round.name,
|
||||
customMessage: customMessage || undefined,
|
||||
fullCustomBody,
|
||||
},
|
||||
},
|
||||
projectId: project.id,
|
||||
userId: userId || undefined,
|
||||
roundId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result = await sendBatchNotifications(items)
|
||||
|
||||
// In-app notifications
|
||||
if (allUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
@@ -1142,12 +1175,12 @@ export const roundRouter = router({
|
||||
action: 'SEND_REJECTION_NOTIFICATIONS',
|
||||
entityType: 'Round',
|
||||
entityId: roundId,
|
||||
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
|
||||
detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { sent, failed }
|
||||
return { sent: result.sent, failed: result.failed }
|
||||
}),
|
||||
|
||||
getBulkInvitePreview: adminProcedure
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||
import { sendStyledNotificationEmail, getAwardSelectionNotificationTemplate } from '@/lib/email'
|
||||
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
|
||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||
import { sendBatchNotifications } from '../services/notification-sender'
|
||||
import type { NotificationItem } from '../services/notification-sender'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
/**
|
||||
@@ -1270,8 +1272,18 @@ export const specialAwardRouter = router({
|
||||
})
|
||||
|
||||
// Get eligible projects that haven't been notified yet
|
||||
// Exclude projects that have been rejected at any stage
|
||||
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
||||
where: { awardId: input.awardId, eligible: true, notifiedAt: null },
|
||||
where: {
|
||||
awardId: input.awardId,
|
||||
eligible: true,
|
||||
notifiedAt: null,
|
||||
project: {
|
||||
projectRoundStates: {
|
||||
none: { state: 'REJECTED' },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
@@ -1324,12 +1336,12 @@ export const specialAwardRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Send emails
|
||||
let emailsSent = 0
|
||||
let emailsFailed = 0
|
||||
// Build notification items — track which eligibility each email belongs to
|
||||
const items: NotificationItem[] = []
|
||||
const eligibilityEmailMap = new Map<string, Set<string>>() // eligibilityId → Set<email>
|
||||
|
||||
for (const e of eligibilities) {
|
||||
const recipients: Array<{ id: string; email: string; name: string | null; passwordHash: string | null }> = []
|
||||
const recipients: Array<{ id: string; email: string; name: string | null }> = []
|
||||
if (e.project.submittedBy) recipients.push(e.project.submittedBy)
|
||||
for (const tm of e.project.teamMembers) {
|
||||
if (!recipients.some((r) => r.id === tm.user.id)) {
|
||||
@@ -1337,39 +1349,46 @@ export const specialAwardRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const emails = new Set<string>()
|
||||
for (const recipient of recipients) {
|
||||
const token = tokenMap.get(recipient.id)
|
||||
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
||||
emails.add(recipient.email)
|
||||
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
recipient.email,
|
||||
recipient.name || '',
|
||||
'AWARD_SELECTION_NOTIFICATION',
|
||||
{
|
||||
title: `Under consideration for ${award.name}`,
|
||||
message: input.customMessage || '',
|
||||
metadata: {
|
||||
projectName: e.project.title,
|
||||
awardName: award.name,
|
||||
customMessage: input.customMessage,
|
||||
accountUrl,
|
||||
},
|
||||
items.push({
|
||||
email: recipient.email,
|
||||
name: recipient.name || '',
|
||||
type: 'AWARD_SELECTION_NOTIFICATION',
|
||||
context: {
|
||||
title: `Under consideration for ${award.name}`,
|
||||
message: input.customMessage || '',
|
||||
metadata: {
|
||||
projectName: e.project.title,
|
||||
awardName: award.name,
|
||||
customMessage: input.customMessage,
|
||||
accountUrl,
|
||||
},
|
||||
)
|
||||
emailsSent++
|
||||
} catch (err) {
|
||||
console.error(`[award-notify] Failed to email ${recipient.email}:`, err)
|
||||
emailsFailed++
|
||||
}
|
||||
},
|
||||
projectId: e.projectId,
|
||||
userId: recipient.id,
|
||||
})
|
||||
}
|
||||
eligibilityEmailMap.set(e.id, emails)
|
||||
}
|
||||
|
||||
// Stamp notifiedAt on all processed eligibilities to prevent re-notification
|
||||
const notifiedIds = eligibilities.map((e) => e.id)
|
||||
if (notifiedIds.length > 0) {
|
||||
const result = await sendBatchNotifications(items)
|
||||
|
||||
// Determine which eligibilities had zero failures
|
||||
const failedEmails = new Set(result.errors.map((e) => e.email))
|
||||
const successfulEligibilityIds: string[] = []
|
||||
for (const [eligId, emails] of eligibilityEmailMap) {
|
||||
const hasFailure = [...emails].some((email) => failedEmails.has(email))
|
||||
if (!hasFailure) successfulEligibilityIds.push(eligId)
|
||||
}
|
||||
|
||||
if (successfulEligibilityIds.length > 0) {
|
||||
await ctx.prisma.awardEligibility.updateMany({
|
||||
where: { id: { in: notifiedIds } },
|
||||
where: { id: { in: successfulEligibilityIds } },
|
||||
data: { notifiedAt: new Date() },
|
||||
})
|
||||
}
|
||||
@@ -1383,14 +1402,15 @@ export const specialAwardRouter = router({
|
||||
detailsJson: {
|
||||
action: 'NOTIFY_ELIGIBLE_PROJECTS',
|
||||
eligibleCount: eligibilities.length,
|
||||
emailsSent,
|
||||
emailsFailed,
|
||||
emailsSent: result.sent,
|
||||
emailsFailed: result.failed,
|
||||
failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { notified: eligibilities.length, emailsSent, emailsFailed }
|
||||
return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed }
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
|
||||
import { transitionProject, isTerminalState } from './round-engine'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import {
|
||||
sendStyledNotificationEmail,
|
||||
getRejectionNotificationTemplate,
|
||||
} from '@/lib/email'
|
||||
import { getRejectionNotificationTemplate } from '@/lib/email'
|
||||
import { createBulkNotifications } from '../services/in-app-notification'
|
||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||
import { sendBatchNotifications } from './notification-sender'
|
||||
import type { NotificationItem } from './notification-sender'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -724,6 +723,7 @@ export async function confirmFinalization(
|
||||
|
||||
const advancedUserIds = new Set<string>()
|
||||
const rejectedUserIds = new Set<string>()
|
||||
const notificationItems: NotificationItem[] = []
|
||||
|
||||
for (const prs of finalizedStates) {
|
||||
type Recipient = { email: string; name: string | null; userId: string | null }
|
||||
@@ -748,53 +748,56 @@ export async function confirmFinalization(
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
if (prs.state === 'PASSED') {
|
||||
// Build account creation URL for passwordless users
|
||||
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
|
||||
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
||||
if (prs.state === 'PASSED') {
|
||||
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
|
||||
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
||||
|
||||
await sendStyledNotificationEmail(
|
||||
recipient.email,
|
||||
recipient.name || '',
|
||||
'ADVANCEMENT_NOTIFICATION',
|
||||
{
|
||||
title: 'Your project has advanced!',
|
||||
message: '',
|
||||
linkUrl: accountUrl || '/applicant',
|
||||
metadata: {
|
||||
projectName: prs.project.title,
|
||||
fromRoundName: round.name,
|
||||
toRoundName: targetRoundName,
|
||||
customMessage: options.advancementMessage || undefined,
|
||||
accountUrl,
|
||||
},
|
||||
notificationItems.push({
|
||||
email: recipient.email,
|
||||
name: recipient.name || '',
|
||||
type: 'ADVANCEMENT_NOTIFICATION',
|
||||
context: {
|
||||
title: 'Your project has advanced!',
|
||||
message: '',
|
||||
linkUrl: accountUrl || '/applicant',
|
||||
metadata: {
|
||||
projectName: prs.project.title,
|
||||
fromRoundName: round.name,
|
||||
toRoundName: targetRoundName,
|
||||
customMessage: options.advancementMessage || undefined,
|
||||
accountUrl,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
await sendStyledNotificationEmail(
|
||||
recipient.email,
|
||||
recipient.name || '',
|
||||
'REJECTION_NOTIFICATION',
|
||||
{
|
||||
title: `Update on your application: "${prs.project.title}"`,
|
||||
message: '',
|
||||
metadata: {
|
||||
projectName: prs.project.title,
|
||||
roundName: round.name,
|
||||
customMessage: options.rejectionMessage || undefined,
|
||||
},
|
||||
},
|
||||
projectId: prs.projectId,
|
||||
userId: recipient.userId || undefined,
|
||||
roundId: round.id,
|
||||
})
|
||||
} else {
|
||||
notificationItems.push({
|
||||
email: recipient.email,
|
||||
name: recipient.name || '',
|
||||
type: 'REJECTION_NOTIFICATION',
|
||||
context: {
|
||||
title: `Update on your application: "${prs.project.title}"`,
|
||||
message: '',
|
||||
metadata: {
|
||||
projectName: prs.project.title,
|
||||
roundName: round.name,
|
||||
customMessage: options.rejectionMessage || undefined,
|
||||
},
|
||||
)
|
||||
}
|
||||
emailsSent++
|
||||
} catch (err) {
|
||||
console.error(`[Finalization] Email failed for ${recipient.email}:`, err)
|
||||
emailsFailed++
|
||||
},
|
||||
projectId: prs.projectId,
|
||||
userId: recipient.userId || undefined,
|
||||
roundId: round.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batchResult = await sendBatchNotifications(notificationItems)
|
||||
emailsSent = batchResult.sent
|
||||
emailsFailed = batchResult.failed
|
||||
|
||||
// Create in-app notifications
|
||||
if (advancedUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function getImageUploadUrl(
|
||||
if (!isValidImageType(contentType)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
|
||||
message: `Invalid image type: "${contentType}". Allowed: JPEG, PNG, GIF, WebP, SVG`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user