Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 36 additions & 0 deletions src/__tests__/onlineEssaySubmissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { keyOnlineEssaySubmissions } from '../utils/onlineEssaySubmissions';

describe('keyOnlineEssaySubmissions', () => {
it('groups student ids by assignment', () => {
const map = keyOnlineEssaySubmissions([
{ assignmentId: 'a1', studentId: 's1' },
{ assignmentId: 'a1', studentId: 's2' },
{ assignmentId: 'a2', studentId: 's3' },
]);
expect(map.get('a1')).toEqual(new Set(['s1', 's2']));
expect(map.get('a2')).toEqual(new Set(['s3']));
});

it('dedupes repeated submissions from the same student', () => {
const map = keyOnlineEssaySubmissions([
{ assignmentId: 'a1', studentId: 's1' },
{ assignmentId: 'a1', studentId: 's1' },
]);
expect(map.get('a1')).toEqual(new Set(['s1']));
});

it('ignores rows without a student id', () => {
const map = keyOnlineEssaySubmissions([
{ assignmentId: 'a1', studentId: '' },
{ assignmentId: 'a1', studentId: 's1' },
]);
expect(map.get('a1')).toEqual(new Set(['s1']));
});

it('returns an empty map for no rows and never for an existing key', () => {
expect(keyOnlineEssaySubmissions([])).toEqual(new Map());
const map = keyOnlineEssaySubmissions([{ assignmentId: 'a1', studentId: 's1' }]);
expect(map.get('missing')).toBeUndefined();
});
});
51 changes: 51 additions & 0 deletions src/__tests__/submitEssayEmail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { resolveStudentEmail } from '../../supabase/functions/submit-essay/email';

describe('submit-essay email resolution', () => {
it('falls back to the client email when the auth record has an EMPTY-STRING email (anonymous session regression)', () => {
// GoTrue anonymous sessions carry an empty-string email claim, not null.
// `authEmail ?? bodyEmail` would let the empty string win and the function
// would reject every anonymous submission as missing studentEmail.
const result = resolveStudentEmail('', 'student@school.nl');
expect(result).toEqual({ email: 'student@school.nl', mismatch: false });
});

it('uses the auth email when present and matching', () => {
expect(resolveStudentEmail('student@school.nl', 'student@school.nl')).toEqual({
email: 'student@school.nl',
mismatch: false,
});
});

it('treats emails case-insensitively', () => {
expect(resolveStudentEmail('Student@School.NL', 'student@school.nl')).toEqual({
email: 'Student@School.NL',
mismatch: false,
});
});

it('flags a mismatch when the auth and client emails differ (403 path)', () => {
const result = resolveStudentEmail('student@school.nl', 'other@school.nl');
expect(result.email).toBe('student@school.nl');
expect(result.mismatch).toBe(true);
});

it('uses the auth email when the client sends none', () => {
expect(resolveStudentEmail('student@school.nl', undefined)).toEqual({
email: 'student@school.nl',
mismatch: false,
});
});

it('uses the client email when there is no auth email at all', () => {
expect(resolveStudentEmail(undefined, 'student@school.nl')).toEqual({
email: 'student@school.nl',
mismatch: false,
});
});

it('resolves to null when neither source has an email', () => {
expect(resolveStudentEmail(undefined, undefined)).toEqual({ email: null, mismatch: false });
expect(resolveStudentEmail('', null)).toEqual({ email: null, mismatch: false });
});
});
63 changes: 63 additions & 0 deletions src/__tests__/submitTestValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
sanitizeAnswers,
isAssignmentExpired,
attemptPolicyFor,
} from '../../supabase/functions/submit-test/validation';

describe('submit-test sanitizeAnswers', () => {
it('strips forged fields like pointsEarned so they never reach storage', () => {
const input = [
{ questionId: 'q1', response: 'Paris', pointsEarned: 999 },
{ questionId: 'q2', response: '4', extra: { whatever: true } },
];
expect(sanitizeAnswers(input)).toEqual([
{ questionId: 'q1', response: 'Paris' },
{ questionId: 'q2', response: '4' },
]);
});

it('preserves questionId/response and order', () => {
const input = [
{ questionId: 'b', response: '2' },
{ questionId: 'a', response: '1' },
];
expect(sanitizeAnswers(input)).toEqual(input);
});
});

describe('submit-test isAssignmentExpired', () => {
const now = new Date('2026-08-17T12:00:00Z');

it('rejects when the deadline is in the past', () => {
expect(isAssignmentExpired('2026-08-17T11:59:59Z', now)).toBe(true);
});

it('allows when the deadline is in the future', () => {
expect(isAssignmentExpired('2026-08-17T12:00:01Z', now)).toBe(false);
});

it('rejects a submission at the exact expiration instant', () => {
expect(isAssignmentExpired('2026-08-17T12:00:00Z', now)).toBe(true);
});

it('allows rows with no deadline', () => {
expect(isAssignmentExpired(null, now)).toBe(false);
expect(isAssignmentExpired(undefined, now)).toBe(false);
});
});

describe('submit-test attemptPolicyFor', () => {
it('practice mode allows up to 5 attempts (retakes)', () => {
expect(attemptPolicyFor('practice')).toEqual({ isPractice: true, maxAttempts: 5 });
});

it('assessment mode allows exactly one attempt', () => {
expect(attemptPolicyFor('assessment')).toEqual({ isPractice: false, maxAttempts: 1 });
});

it('legacy rows with no mode keep the one-attempt guard', () => {
expect(attemptPolicyFor(undefined)).toEqual({ isPractice: false, maxAttempts: 1 });
expect(attemptPolicyFor(null)).toEqual({ isPractice: false, maxAttempts: 1 });
});
});
27 changes: 25 additions & 2 deletions src/hooks/useLiveSessionTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ export interface UseLiveSessionTelemetryReturn {
flush: () => ProctorEvent[];
/** True once a Realtime broadcast channel is active (DB mode, enabled). */
isBroadcasting: boolean;
/**
* Sends a one-off broadcast on the active session channel and resolves with
* the server acknowledgement ('ok'), a timeout, or an error — or 'ok'
* immediately when no channel is live. Used for state transitions the
* channel owner needs to see — e.g. the student broadcasting 'submitted'
* just before `enabled` flips false and tears the channel down. Callers
* that gate UI on the broadcast (like the submitted confirmation) should
* await it; the channel is configured with `broadcast.ack: true` so the
* promise only resolves once the Realtime server confirmed receipt.
*/
broadcast: (event: string, payload?: unknown) => Promise<'ok' | 'timed out' | 'error'>;
}

function shallowEqualSnapshot(a: LiveSessionSnapshot | null, b: LiveSessionSnapshot): boolean {
Expand Down Expand Up @@ -104,6 +115,13 @@ export function useLiveSessionTelemetry({
channelRef.current?.send({ type: 'broadcast', event: 'event', payload: event });
}, []);

const broadcast = useCallback((event: string, payload?: unknown): Promise<'ok' | 'timed out' | 'error'> => {
// send() is typed as a branded `string` in this realtime-js version; its runtime
// values are exactly 'ok' | 'timed out' | 'error' (see RealtimeChannel.send).
return (channelRef.current?.send({ type: 'broadcast', event, payload }) ??
Promise.resolve('ok' as const)) as Promise<'ok' | 'timed out' | 'error'>;
}, []);

const flush = useCallback((): ProctorEvent[] => {
const result = eventsRef.current;
eventsRef.current = [];
Expand All @@ -120,7 +138,12 @@ export function useLiveSessionTelemetry({
const client = createClient(supabaseUrl!, supabaseAnonKey!, {
auth: { persistSession: false, autoRefreshToken: false, storageKey: 'rm_monitor_ephemeral' },
});
const channel = client.channel(`monitor:${kind}:${assignmentKey}`);
// ack:true makes send() resolve only after the server confirms receipt —
// important for the 'submitted' broadcast, which is fire-and-forget today
// but must not be silently dropped right before the channel tears down.
const channel = client.channel(`monitor:${kind}:${assignmentKey}`, {
config: { broadcast: { ack: true, self: false } },
});
channel.on('broadcast', { event: 'nudge' }, ({ payload }) => {
onNudgeRef.current?.((payload as { message: string }).message);
});
Expand Down Expand Up @@ -260,5 +283,5 @@ export function useLiveSessionTelemetry({
return () => clearInterval(interval);
}, [enabled, getSnapshot, hasDb]);

return { events, flush, isBroadcasting };
return { events, flush, isBroadcasting, broadcast };
}
39 changes: 39 additions & 0 deletions src/hooks/useOnlineEssaySubmissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react';
import { useEssays } from '../context/AppContext';
import { loadSupabaseConfig } from '../services/database';
import { keyOnlineEssaySubmissions } from '../utils/onlineEssaySubmissions';

/**
* Online essay submissions (essay_submissions rows written by the submit-essay
* edge function), keyed teacherKey -> set of student ids that handed in.
*
* The store's hydrated `essaySubmissions` only tracks OFFLINE submissions
* (essay_offline_submissions — the pasted-code import path), so roster badges
* and submitted counts would never reflect a student who handed in through the
* online portal. This fetches the online table separately, purely for status
* derivation — the result is never persisted back.
*/
export function useOnlineEssaySubmissions(): Map<string, Set<string>> {
const { fetchAllEssaySubmissions } = useEssays();
const config = loadSupabaseConfig();
const hasDb = !!config?.supabaseUrl && !!config?.supabaseAnonKey;
const [byTeacherKey, setByTeacherKey] = useState<Map<string, Set<string>>>(new Map());

useEffect(() => {
if (!hasDb) return;
let cancelled = false;
void fetchAllEssaySubmissions()
.then((rows) => {
if (cancelled) return;
setByTeacherKey(keyOnlineEssaySubmissions(rows));
})
.catch(() => {
// Non-fatal: offline submissions still drive the badges.
});
return () => {
cancelled = true;
};
}, [hasDb, fetchAllEssaySubmissions]);

return byTeacherKey;
}
21 changes: 19 additions & 2 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,25 @@ function isStudentRoute() {
const router = createHashRouter([
{ path: '/feedback/:code', element: <StudentFeedbackPage /> },
{ path: '/preview/:code', element: <RubricPreviewPage /> },
{ path: '/essay/:code', element: <StudentEssayPage /> },
{ path: '/test/:code', element: <StudentTestPage /> },
// Student pages need toasts (e.g. the live-monitor nudge check-in banner), so
// wrap them in ToastProvider like the main app route — without it, the
// ToastContext default makes showToast a silent no-op.
{
path: '/essay/:code',
element: (
<ToastProvider>
<StudentEssayPage />
</ToastProvider>
),
},
{
path: '/test/:code',
element: (
<ToastProvider>
<StudentTestPage />
</ToastProvider>
),
},
{
path: '*',
element: (
Expand Down
14 changes: 11 additions & 3 deletions src/pages/EssayBuilderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Topbar from '../components/Layout/Topbar';
import Modal from '../components/ui/Modal';
import { useAuthoring, useClasses, useEssays, useStudents } from '../context/AppContext';
import { useToast } from '../hooks/useToast';
import { useOnlineEssaySubmissions } from '../hooks/useOnlineEssaySubmissions';
import EssayAssignmentModal from '../components/Essay/EssayAssignmentModal';
import EssaySlipSheet from '../components/Essay/EssaySlipSheet';
import { encodeEssayAssignment } from '../utils/shareCode';
Expand All @@ -27,6 +28,10 @@ export default function EssayBuilderPage() {
const { rubrics } = useAuthoring();
const { essayAssignments, essaySubmissions, addEssayAssignments, updateEssayGroup, addEssaySubmission } =
useEssays();
// Online hand-ins (essay_submissions via submit-essay) are NOT part of the
// hydrated `essaySubmissions` (which tracks only offline pasted-code imports),
// so the roster badge would stay Pending for a portal submission without this.
const onlineEssaySubmissions = useOnlineEssaySubmissions();

// The canonical group is every row saved under this exact teacherKey (the bulk
// "Assign to Students" fan-out). `existing` is looked up directly rather than via
Expand Down Expand Up @@ -372,9 +377,12 @@ export default function EssayBuilderPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
{rows.map((row) => {
const student = students.find((s) => s.id === row.studentId);
const submitted = essaySubmissions.some(
(s) => s.teacherKey === row.teacherKey && s.assignmentStudentId === row.studentId
);
const submitted =
essaySubmissions.some(
(s) =>
s.teacherKey === row.teacherKey && s.assignmentStudentId === row.studentId
) ||
(onlineEssaySubmissions.get(row.teacherKey)?.has(row.studentId) ?? false);
// A row can share this rubric without belonging to this page's
// bulk group — e.g. assigned individually from GradeStudent.
const isIndividual = row.teacherKey !== teacherKeyParam;
Expand Down
15 changes: 11 additions & 4 deletions src/pages/EssayListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Topbar from '../components/Layout/Topbar';
import { useAuthoring, useClasses, useEssays, useStudents } from '../context/AppContext';
import { ConfirmDialog } from '../components/ui/ConfirmDialog';
import { useConfirm } from '../hooks/useConfirm';
import { useOnlineEssaySubmissions } from '../hooks/useOnlineEssaySubmissions';
import { sortByDisplayOrder, reorderDisplayOrder } from '../utils/displayOrder';
import { getCohortStudentIds, isAllCohorts, ALL_COHORTS } from '../utils/cohortAggregator';
import type { CohortFilter as CohortFilterValue } from '../types';
Expand All @@ -20,6 +21,7 @@ export default function EssayListPage() {

const { rubrics } = useAuthoring();
const { essayAssignments, essaySubmissions, deleteEssayGroup, updateEssayGroup } = useEssays();
const onlineEssaySubmissions = useOnlineEssaySubmissions();

const { confirm, dialogProps: confirmDialogProps } = useConfirm();
const [cohortFilter, setCohortFilter] = React.useState<CohortFilterValue>(ALL_COHORTS);
Expand Down Expand Up @@ -104,11 +106,16 @@ export default function EssayListPage() {
{groups.map(({ teacherKey, rows }, idx) => {
const first = rows[0];
const rubric = rubrics.find((r) => r.id === first.rubricId);
const submittedCount = new Set(
essaySubmissions
// Offline (pasted-code) submissions come from the hydrated
// essaySubmissions; online hand-ins (essay_submissions via
// submit-essay) are added via useOnlineEssaySubmissions —
// otherwise the count would stay 0 for portal submissions.
const submittedCount = new Set([
...essaySubmissions
.filter((s) => s.teacherKey === teacherKey)
.map((s) => s.assignmentStudentId)
).size;
.map((s) => s.assignmentStudentId),
...(onlineEssaySubmissions.get(teacherKey) ?? []),
]).size;
return (
<Draggable
key={teacherKey}
Expand Down
Loading
Loading