Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c26de2c
feat: add assessment reminder card to settings page
rappm Feb 14, 2026
5c201ff
feat: add manual mail sending functionality and related tests
rappm Feb 14, 2026
514cdad
Merge remote-tracking branch 'origin/main' into 618-send-automatic-re…
rappm Mar 17, 2026
8ecceef
Refactor: Update import paths and enhance test suite error handling
rappm Mar 17, 2026
eb70228
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Mar 17, 2026
c57b4ff
Enhance assessment reminder functionality and error handling
rappm Mar 17, 2026
a9dffc2
Merge branch '618-send-automatic-reminders-to-assessors-once-assessme…
rappm Mar 17, 2026
c78d624
Refactor: Simplify core course phase update and participant mailing i…
rappm Mar 17, 2026
ccfc4a1
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Mar 17, 2026
101fddb
Merge remote-tracking branch 'origin/main' into 618-send-automatic-re…
rappm Mar 20, 2026
09b59e3
Fix: Preserve existing restricted data when updating mailing settings…
rappm Mar 20, 2026
6405aeb
feat: Implement manual reminder sending and confirmation dialog in As…
rappm Mar 21, 2026
f7258f9
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Mar 23, 2026
ea52167
Fix assessment reminder review comments
rappm Mar 23, 2026
6239c9d
Merge remote-tracking branch 'origin/main' into 618-send-automatic-re…
rappm Mar 25, 2026
6bd9136
Enhance Assessment Reminder Components and Logic
rappm Mar 25, 2026
6846f62
Add reminder type validation and alert in AssessmentReminderCard
rappm Mar 25, 2026
d124b76
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Mar 25, 2026
d7c0696
Merge remote-tracking branch 'origin/main' into 618-send-automatic-re…
rappm Mar 26, 2026
4154a9f
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Mar 31, 2026
3d5d58d
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Apr 7, 2026
d80a076
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Apr 12, 2026
d2ac721
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Apr 13, 2026
04cfa81
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Apr 15, 2026
2946f19
feat: implement AssessmentReminderCard and update reminder sending se…
rappm Apr 16, 2026
59aa88f
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm Apr 22, 2026
87075fc
Merge remote-tracking branch 'origin/main' into 618-send-automatic-re…
rappm Apr 28, 2026
1792992
Refactor assessment reminder components and improve error handling fo…
rappm Apr 28, 2026
e1ad877
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm May 14, 2026
3c5f6a0
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
JGStyle May 15, 2026
089e026
Merge branch 'main' into 618-send-automatic-reminders-to-assessors-on…
rappm May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ build/
# Go / Backend
# =============================================================================
**/.gocache/
**/.cache/

# =============================================================================
# IDE & Editor
Expand Down
1 change: 1 addition & 0 deletions clients/assessment_component/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "webpack serve --open --mode development",
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"test": "../node_modules/.bin/ts-node --compiler-options '{\"module\":\"commonjs\"}' src/assessment/pages/SettingsPage/components/AssessmentReminderCard/utils.test.ts",
"build": "webpack --mode=production --env NODE_ENV=production",
"check-performance": "webpack --mode=production --env NODE_ENV=production --env BUNDLE_SIZE=true"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type EvaluationReminderType = 'self' | 'peer' | 'tutor'

export interface AssessmentReminderMetaData {
subject: string
content: string
lastSentAtByType: Partial<Record<EvaluationReminderType, string>>
}

export interface SendEvaluationReminderRequest {
evaluationType: EvaluationReminderType
}

export interface EvaluationReminderReport {
successfulEmails: string[]
failedEmails: string[]
requestedRecipients: number
evaluationType: EvaluationReminderType
deadline?: string
deadlinePassed: boolean
sentAt: string
previousSentAt?: string | null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { axiosInstance } from '@/network/configService'
import {
SendEvaluationReminderRequest,
EvaluationReminderReport,
} from '../../interfaces/evaluationReminder'

export const sendEvaluationReminder = async (
coursePhaseID: string,
request: SendEvaluationReminderRequest,
): Promise<EvaluationReminderReport> => {
try {
return (
await axiosInstance.post(
`/assessment/api/course_phase/${coursePhaseID}/config/reminders/send`,
request,
{
headers: {
'Content-Type': 'application/json',
},
},
)
).data
} catch (err) {
console.error('Failed to send evaluation reminder:', err)
throw err
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ScoreLevelDistributionDiagram } from '../components/diagrams/ScoreLevel

import { CoursePhaseConfigSelection } from './components/CoursePhaseConfigSelection/CoursePhaseConfigSelection'
import { CategoryList } from './components/CategoryList/CategoryList'
import { AssessmentReminderCard } from './components/AssessmentReminderCard'

export const SettingsPage = () => {
const [showReleaseDialog, setShowReleaseDialog] = useState(false)
Expand Down Expand Up @@ -100,6 +101,8 @@ export const SettingsPage = () => {
hasTutorEvalData={tutorEvalSchemaData?.hasAssessmentData ?? false}
/>

{(isPromptAdmin || isLecturer) && <AssessmentReminderCard />}

{(isPromptAdmin || isLecturer) && !config?.resultsReleased && (
<div className='w-full'>
<Button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { AxiosError } from 'axios'
import type { CoursePhaseWithMetaData } from '@tumaet/prompt-shared-state'
import { useParams } from 'react-router-dom'

import { AvailableMailPlaceholders } from '@/components/pages/Mailing/components/AvailableMailPlaceholders'
import { useGetMailingIsConfigured } from '@/hooks/useGetMailingIsConfigured'
import { useModifyCoursePhase } from '@/hooks/useModifyCoursePhase'
import { getCoursePhase } from '@/network/queries/getCoursePhase'
import {
Alert,
AlertDescription,
AlertTitle,
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
useToast,
} from '@tumaet/prompt-ui-components'

import type { EvaluationCompletion } from '../../../interfaces/evaluationCompletion'
import type {
AssessmentReminderMetaData,
EvaluationReminderReport,
EvaluationReminderType,
} from '../../../interfaces/evaluationReminder'
import { sendEvaluationReminder } from '../../../network/mutations/sendEvaluationReminder'
import { getAllEvaluationCompletionsInPhase } from '../../../network/queries/getAllEvaluationCompletionsInPhase'
import { useCoursePhaseConfigStore } from '../../../zustand/useCoursePhaseConfigStore'
import { useParticipationStore } from '../../../zustand/useParticipationStore'
import { useTeamStore } from '../../../zustand/useTeamStore'
import { ManualReminderSendingSection } from './AssessmentReminderCard/components/ManualReminderSendingSection'
import { ReminderSendConfirmationDialog } from './AssessmentReminderCard/components/ReminderSendConfirmationDialog'
import { ReminderTemplateEditor } from './AssessmentReminderCard/components/ReminderTemplateEditor'
import {
ASSESSMENT_REMINDER_PLACEHOLDERS,
deadlinePassed,
EMPTY_REMINDER_META,
formatDeadline,
getReminderTypes,
parseReminderMetaData,
} from './AssessmentReminderCard/utils'

interface ErrorResponse {
error?: string
}

export const AssessmentReminderCard = () => {
const { phaseId } = useParams<{ phaseId: string }>()
const { toast } = useToast()
const queryClient = useQueryClient()

const { coursePhaseConfig } = useCoursePhaseConfigStore()
const { participations } = useParticipationStore()
const { teams } = useTeamStore()
const courseMailingIsConfigured = useGetMailingIsConfigured()

const [subject, setSubject] = useState('')
const [content, setContent] = useState('')
const [initialMetaData, setInitialMetaData] = useState(EMPTY_REMINDER_META)
const [confirmationType, setConfirmationType] = useState<EvaluationReminderType | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [lastReport, setLastReport] = useState<EvaluationReminderReport | null>(null)
const contentTextareaRef = useRef<HTMLTextAreaElement>(null)
const initializedPhaseIdRef = useRef<string | null>(null)

const {
data: coursePhase,
isPending: isCoursePhasePending,
isError: isCoursePhaseError,
} = useQuery<CoursePhaseWithMetaData>({
queryKey: ['course_phase', phaseId],
queryFn: () => getCoursePhase(phaseId ?? ''),
enabled: !!phaseId,
})

const { data: evaluationCompletions, isPending: isEvaluationCompletionsPending } = useQuery<
EvaluationCompletion[]
>({
queryKey: ['evaluationCompletions', phaseId],
queryFn: () => getAllEvaluationCompletionsInPhase(phaseId ?? ''),
enabled: !!phaseId,
})

const { mutate: updateCoursePhase, isPending: isSavingTemplate } = useModifyCoursePhase(
() => {
toast({ title: 'Assessment reminder template updated' })
queryClient.invalidateQueries({ queryKey: ['course_phase', phaseId] })
},
() => {
toast({
title: 'Failed to update reminder template',
description: 'Please try again later.',
variant: 'destructive',
})
},
)

const sendReminderMutation = useMutation({
mutationFn: (type: EvaluationReminderType) =>
sendEvaluationReminder(phaseId ?? '', { evaluationType: type }),
onSuccess: (report) => {
setLastReport(report)
toast({
title: `Reminder sent for ${report.evaluationType} evaluation`,
description: `Successful: ${report.successfulEmails.length}, Failed: ${report.failedEmails.length}`,
})
queryClient.invalidateQueries({ queryKey: ['course_phase', phaseId] })
},
onError: (error: AxiosError<ErrorResponse>) => {
const serverError = error.response?.data?.error ?? 'Failed to send reminder emails.'
toast({
title: 'Reminder sending failed',
description: serverError,
variant: 'destructive',
})
},
onSettled: () => {
setDialogOpen(false)
setConfirmationType(null)
},
})

useEffect(() => {
if (!coursePhase) return

const currentPhaseId = phaseId ?? coursePhase.id
if (initializedPhaseIdRef.current === currentPhaseId) return

const parsed = parseReminderMetaData(coursePhase)
setInitialMetaData(parsed)
setSubject(parsed.subject)
setContent(parsed.content)
initializedPhaseIdRef.current = currentPhaseId
}, [coursePhase, phaseId])

useEffect(() => {
if (!contentTextareaRef.current) return

contentTextareaRef.current.style.height = 'auto'
contentTextareaRef.current.style.height = `${contentTextareaRef.current.scrollHeight}px`
}, [content])

const currentReminderMetaData = useMemo(() => parseReminderMetaData(coursePhase), [coursePhase])
const reminderTypes = useMemo(
() => getReminderTypes(coursePhaseConfig, participations, teams, evaluationCompletions),
[coursePhaseConfig, participations, teams, evaluationCompletions],
)
const confirmationReminderType = useMemo(
() => reminderTypes.find((reminderType) => reminderType.type === confirmationType) ?? null,
[confirmationType, reminderTypes],
)

const isModified = subject !== initialMetaData.subject || content !== initialMetaData.content
const templateComplete = subject.trim() !== '' && content.trim() !== ''

const handleSaveTemplate = () => {
if (!phaseId || !coursePhase) return

const mailingSettings =
(coursePhase.restrictedData?.mailingSettings as Record<string, unknown>) ?? {}
const updatedReminder: AssessmentReminderMetaData = {
subject,
content,
lastSentAtByType: currentReminderMetaData.lastSentAtByType ?? {},
}

updateCoursePhase({
id: coursePhase.id,
name: coursePhase.name,
studentReadableData: coursePhase.studentReadableData ?? {},
restrictedData: {
...coursePhase.restrictedData,
mailingSettings: {
...mailingSettings,
assessmentReminder: updatedReminder,
},
},
})
}

const openConfirmationDialog = (type: EvaluationReminderType) => {
setConfirmationType(type)
setDialogOpen(true)
}

const sendConfirmedReminder = () => {
if (!confirmationType) return
sendReminderMutation.mutate(confirmationType)
}

const handleDialogOpenChange = (open: boolean) => {
setDialogOpen(open)
if (!open) {
setConfirmationType(null)
}
}

const getDisableReason = (reminderType: (typeof reminderTypes)[number]): string | undefined => {
if (isEvaluationCompletionsPending) {
return 'Wait until recipient counts have finished loading.'
}
if (isModified) return 'Save template changes before sending reminders.'
if (!courseMailingIsConfigured) return 'Configure course mailing reply-to settings first.'
if (!templateComplete) return 'Reminder subject and content are required.'
if (!deadlinePassed(reminderType.deadline))
return `${reminderType.label} deadline has not passed yet (${formatDeadline(reminderType.deadline)}).`
return undefined
}

return (
<Card>
<CardHeader>
<div className='flex items-center justify-between gap-2'>
<CardTitle>Evaluation Reminder Mailing</CardTitle>
{isModified && (
<Badge variant='outline' className='border-yellow-300 bg-yellow-100 text-yellow-800'>
Unsaved Changes
</Badge>
)}
</div>
<CardDescription>
Configure one shared reminder template and manually send reminders after each evaluation
deadline.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<AvailableMailPlaceholders
customAdditionalPlaceholders={ASSESSMENT_REMINDER_PLACEHOLDERS}
/>

{!courseMailingIsConfigured && (
<Alert variant='destructive'>
<AlertTitle>Course mailing not configured</AlertTitle>
<AlertDescription>
Configure the course reply-to mailing settings before sending reminders.
</AlertDescription>
</Alert>
)}

{isCoursePhaseError && (
<Alert variant='destructive'>
<AlertTitle>Failed to load phase metadata</AlertTitle>
<AlertDescription>Cannot load existing reminder template settings.</AlertDescription>
</Alert>
)}

{lastReport && (
<Alert>
<AlertTitle>Reminder send report</AlertTitle>
<AlertDescription>
Requested: {lastReport.requestedRecipients}, Successful:{' '}
{lastReport.successfulEmails.length}, Failed: {lastReport.failedEmails.length}
</AlertDescription>
</Alert>
)}

<ReminderTemplateEditor
subject={subject}
content={content}
onSubjectChange={setSubject}
onContentChange={setContent}
onSave={handleSaveTemplate}
isPending={isCoursePhasePending}
isSaving={isSavingTemplate}
isModified={isModified}
contentTextareaRef={contentTextareaRef}
/>

<ManualReminderSendingSection
reminderTypes={reminderTypes}
lastSentAtByType={currentReminderMetaData.lastSentAtByType}
getDisableReason={getDisableReason}
isEvaluationCompletionsPending={isEvaluationCompletionsPending}
isSending={sendReminderMutation.isPending}
onSend={openConfirmationDialog}
/>
</CardContent>

<ReminderSendConfirmationDialog
open={dialogOpen}
onOpenChange={handleDialogOpenChange}
confirmationReminderType={confirmationReminderType}
previousSentAt={
confirmationType ? currentReminderMetaData.lastSentAtByType[confirmationType] : undefined
}
isSending={sendReminderMutation.isPending}
onConfirm={sendConfirmedReminder}
/>
</Card>
)
}
Loading
Loading