feat: Permettre à l'enseignant de saisir les notes dans une grille inline
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

L'enseignant avait besoin d'un moyen rapide de saisir les notes après
une évaluation. La grille inline permet de compléter 30 élèves en moins
de 3 minutes grâce à la navigation clavier (Tab/Enter/Shift+Tab),
la validation temps réel, l'auto-save debounced (500ms) et les
raccourcis /abs et /disp pour marquer absents/dispensés.

Les notes restent en brouillon jusqu'à publication explicite (avec
confirmation modale). Une fois publiées, les élèves les voient
immédiatement ; les parents après un délai de 24h (VisibiliteNotesPolicy).
Le mode offline stocke les notes en IndexedDB et synchronise
automatiquement au retour de la connexion.

Chaque modification est auditée dans grade_events via un event
subscriber qui écoute NoteSaisie/NoteModifiee sur le bus d'événements.
This commit is contained in:
2026-03-29 09:55:45 +02:00
parent 98be1951bf
commit b70d5ec2ad
45 changed files with 3902 additions and 11 deletions

View File

@@ -46,7 +46,9 @@ function cleanupClasses() {
`DELETE FROM homework_rule_exceptions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM homework_attachments WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}'`,
// Delete evaluations
// Delete evaluations (grades cleaned by global-setup)
`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}'`,
`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}'`,
// Delete schedule slots (CASCADE on FK, but be explicit)
`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`,

View File

@@ -126,6 +126,8 @@ test.describe('Evaluation Management (Story 6.1)', () => {
// Clean up ALL evaluations for this teacher (not just by tenant, to avoid
// stale data from parallel test files with different teachers)
try {
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.tenant_id = '${TENANT_ID}' AND e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch {
// Table may not exist
@@ -435,6 +437,8 @@ test.describe('Evaluation Management (Story 6.1)', () => {
// Cleanup: remove the second class data
try {
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND e.class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'`);

View File

@@ -5,21 +5,41 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
/**
* Global setup for E2E tests.
*
* - Resets rate limiter to ensure tests start with clean state
* - Cleans transactional data that could cause FK constraint failures
* (each test file handles its own specific cleanup in beforeAll/beforeEach)
* - Resets rate limiter cache
* - Token creation is handled per-browser in test files using beforeAll hooks
*/
async function globalSetup() {
console.warn('🎭 E2E Global setup - tokens are created per browser project');
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
// Clean grade data (Story 6.2) to prevent FK constraint failures
// when other tests try to DELETE FROM evaluations
try {
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}'`);
console.warn('✅ Grade data cleaned');
} catch {
// Tables may not exist yet
}
// Reset rate limiter to prevent failed login tests from blocking other tests
try {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
// Use Symfony cache:pool:clear for more reliable cache clearing
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`,
{ encoding: 'utf-8' }

383
frontend/e2e/grades.spec.ts Normal file
View File

@@ -0,0 +1,383 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const TEACHER_EMAIL = 'e2e-grade-teacher@example.com';
const TEACHER_PASSWORD = 'GradeTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// Deterministic IDs for test data
let evaluationId: string;
let classId: string;
let student1Id: string;
let student2Id: string;
test.describe('Grade Input Grid (Story 6.2)', () => {
test.beforeAll(async () => {
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Create test class
const classOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-class-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
classId = classOutput;
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-GRADE-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Create test subject
const subjectOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-subject-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const subjectId = subjectOutput;
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-GRADE-Sciences', 'E2GRDSCI', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Create teacher assignment
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Create 2 test students
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student1@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Alice --lastName=Durand 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student2@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Bob --lastName=Martin 2>&1`,
{ encoding: 'utf-8' }
);
// Assign students to class
const studentIds = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`,
{ encoding: 'utf-8' }
);
const idMatches = studentIds.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g);
if (idMatches && idMatches.length >= 2) {
student1Id = idMatches[0]!;
student2Id = idMatches[1]!;
}
// Assign students to class
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${student1Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
);
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${student2Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
);
// Create test evaluation
const evalOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-eval-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
evaluationId = evalOutput;
clearCache();
});
test.beforeEach(async () => {
// Clean grades and recreate evaluation
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE evaluation_id = '${evaluationId}')`);
runSql(`DELETE FROM grades WHERE evaluation_id = '${evaluationId}'`);
runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`);
runSql(
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
`SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', ` +
`(SELECT id FROM subjects WHERE code='E2GRDSCI' AND tenant_id='${TENANT_ID}' LIMIT 1), ` +
`u.id, 'E2E Contrôle Sciences', '2026-04-15', 20, 1.0, 'published', NULL, NOW(), NOW() ` +
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
`ON CONFLICT (id) DO UPDATE SET grades_published_at = NULL, updated_at = NOW()`
);
clearCache();
});
test.describe('Grade Grid Display', () => {
test('shows grade input grid with students', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
// Should display evaluation title
await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 });
// Should show grade inputs
const gradeInputs = page.locator('.grade-input');
await expect(gradeInputs.first()).toBeVisible({ timeout: 10000 });
});
test('"Saisir les notes" button navigates to grades page', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`);
await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 });
// Wait for evaluation cards to load
await expect(page.getByText('E2E Contrôle Sciences')).toBeVisible({ timeout: 10000 });
await page.getByRole('link', { name: /saisir les notes/i }).first().click();
await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 });
});
});
test.describe('Grade Input', () => {
test('can enter a numeric grade', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('15.5');
// Should show the grade in status column
await expect(page.locator('.status-graded').first()).toBeVisible({ timeout: 5000 });
});
test('validates grade against scale maximum', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('25');
// Should show error
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 });
});
});
test.describe('Slash Commands', () => {
test('/abs marks student as absent', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('/abs');
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 5000 });
});
test('/disp marks student as dispensed', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('/disp');
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 5000 });
});
});
test.describe('Keyboard Navigation', () => {
test('Tab moves to next student', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.focus();
await firstInput.press('Tab');
// Second input should be focused
const secondInput = page.locator('.grade-input').nth(1);
await expect(secondInput).toBeFocused({ timeout: 3000 });
});
test('Enter moves to next student', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('15');
await firstInput.press('Enter');
// Second input should be focused
const secondInput = page.locator('.grade-input').nth(1);
await expect(secondInput).toBeFocused({ timeout: 3000 });
});
test('Shift+Tab moves to previous student', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const secondInput = page.locator('.grade-input').nth(1);
await secondInput.focus();
await secondInput.press('Shift+Tab');
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeFocused({ timeout: 3000 });
});
});
test.describe('Publication', () => {
test('publish requires confirmation via modal', async ({ page }) => {
clearCache();
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await page.waitForLoadState('networkidle');
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
// Start listening for the PUT response BEFORE triggering the save
const savePromise = page.waitForResponse(
(resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
// Enter grades
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
await firstInput.fill('18');
await expect(page.locator('.status-graded').first()).toContainText('18/20', { timeout: 10000 });
// Wait for the PUT to complete
await savePromise;
// Click publish button — opens confirmation modal
await page.getByRole('button', { name: /publier les notes/i }).click();
// Confirmation dialog should appear
const dialog = page.getByRole('alertdialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible();
// Start listening for POST before clicking confirm
const publishPromise = page.waitForResponse(
(resp) => resp.url().includes('/publish') && resp.request().method() === 'POST',
{ timeout: 30000 }
);
// Confirm publication
await dialog.getByRole('button', { name: /confirmer la publication/i }).click();
// Wait for the POST to complete
await publishPromise;
// Should show published badge
await expect(page.getByText(/notes publiées/i)).toBeVisible({ timeout: 10000 });
});
test('publish modal can be cancelled', async ({ page }) => {
clearCache();
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await page.waitForLoadState('networkidle');
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
// Start listening for PUT before triggering
const savePromise = page.waitForResponse(
(resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
// Enter a grade
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
await firstInput.fill('14');
await expect(page.locator('.status-graded').first()).toContainText('14/20', { timeout: 10000 });
// Wait for save to complete
await savePromise;
const publishBtn = page.getByRole('button', { name: /publier les notes/i });
// Open modal then cancel
await publishBtn.click();
const dialog = page.getByRole('alertdialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible();
await dialog.getByRole('button', { name: /annuler/i }).click();
// Modal should close, publish button still visible
await expect(dialog).not.toBeVisible({ timeout: 3000 });
await expect(publishBtn).toBeVisible();
});
});
});

View File

@@ -46,7 +46,9 @@ function cleanupSubjects() {
`DELETE FROM homework_rule_exceptions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM homework_attachments WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}'`,
// Delete evaluations (subjects FK)
// Delete grades and evaluations (grades FK → evaluations, evaluations FK → subjects)
`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}'`,
`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}'`,
// Delete schedule slots (subjects FK with CASCADE)
`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`,