feat: per-round advancement selection, email preview, Docker/auth fixes
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m42s

- Bulk notification dialog: per-round checkboxes (default none selected),
  selected count badge, "Preview Email" button with rendered iframe
- Backend: roundIds filter on sendBulkPassedNotifications, new
  previewAdvancementEmail query
- Docker: add external MinIO network so app container can reach MinIO
- File router: try/catch on getPresignedUrl with descriptive error
- Auth: custom NextAuth logger suppresses CredentialsSignin stack traces

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 14:31:01 +01:00
parent 267d26581d
commit af03c12ae5
5 changed files with 410 additions and 228 deletions

View File

@@ -111,9 +111,18 @@ export const fileRouter = router({
}
}
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900,
input.forDownload ? { downloadFileName: input.fileName || input.objectKey.split('/').pop() || 'download' } : undefined
) // 15 min
let url: string
try {
url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900,
input.forDownload ? { downloadFileName: input.fileName || input.objectKey.split('/').pop() || 'download' } : undefined
) // 15 min
} catch (err) {
console.error('[file] getPresignedUrl failed:', input.objectKey, err instanceof Error ? err.message : err)
throw new TRPCError({
code: 'NOT_FOUND',
message: `File not available in storage: ${err instanceof Error ? err.message : 'unknown error'}`,
})
}
// Log file access
await logAudit({

View File

@@ -1670,20 +1670,63 @@ export const projectRouter = router({
* Groups by round, determines next round, sends via batch sender.
* Skips projects that have already been notified (unless skipAlreadySent=false).
*/
previewAdvancementEmail: adminProcedure
.input(
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: {
name: true,
competitionId: true,
},
})
if (!round) throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
const rounds = await ctx.prisma.round.findMany({
where: { competitionId: round.competitionId },
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true },
})
const idx = rounds.findIndex((r) => r.id === input.roundId)
const nextRound = rounds[idx + 1]
const { getAdvancementNotificationTemplate } = await import('@/lib/email')
const template = getAdvancementNotificationTemplate(
'Team Lead Name',
'Example Project Title',
round.name,
nextRound?.name ?? 'Next Round',
input.customMessage || undefined,
undefined,
input.fullCustomBody,
)
return { subject: template.subject, html: template.html }
}),
sendBulkPassedNotifications: adminProcedure
.input(
z.object({
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
skipAlreadySent: z.boolean().default(true),
roundIds: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { customMessage, fullCustomBody, skipAlreadySent } = input
const { customMessage, fullCustomBody, skipAlreadySent, roundIds } = input
// Find all PASSED project round states
// Find all PASSED project round states (optionally filtered by round)
const passedStates = await ctx.prisma.projectRoundState.findMany({
where: { state: 'PASSED' },
where: {
state: 'PASSED',
...(roundIds && roundIds.length > 0 ? { roundId: { in: roundIds } } : {}),
},
select: {
projectId: true,
roundId: true,