feat: teacher_students — назначения ученикам без класса

Новая модель «Мои ученики» — учитель связывает с собой учеников
независимо от классов (репетиторский сценарий).

Backend:
  - Таблица teacher_students (teacher_id, student_id, added_at, note)
    + индекс на student_id для обратного поиска
  - GET/POST/PATCH/DELETE /api/teacher-students — управление списком
  - Добавление по email с проверкой роли student/free_student
  - Уведомление ученику при добавлении

  - createDirectAssignment: проверка inClass расширена до
    inClass OR (teacher_id, student_id) в teacher_students
  - listStudents (/api/classes/students): возвращает объединение
    учеников из классов + из teacher_students. Это автоматически
    обновляет student-picker в /textbooks без правок UI.

Frontend:
  - /my-students — таблица личных учеников + форма добавления
    по email + заметка + счётчик созданных заданий
  - Сайдбар: пункт «Мои ученики» (user-plus, только для учителей)

Миграция 006_teacher_students.sql.

Что работает end-to-end:
  - Добавить ученика на /my-students
  - Открыть /textbooks → «Назначить» → «Ученику» → ученик ищется
    в общем списке (классовые + личные)
  - Создаётся запись в assignments с user_id, видна ученику на
    дашборде с пометкой «Личное задание»
@
This commit is contained in:
Maxim Dolgolyov
2026-05-16 17:01:11 +03:00
parent 3ff2f01178
commit eeb79246db
7 changed files with 409 additions and 9 deletions
@@ -529,14 +529,21 @@ function createDirectAssignment(req, res) {
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
}
// Учитель может выдать личное задание только ученику из своего класса
// Учитель может выдать личное задание ученику из своего класса ИЛИ из «Мои ученики»
if (req.user.role === 'teacher') {
const inClass = db.prepare(`
SELECT 1 FROM class_members cm
JOIN classes c ON c.id = cm.class_id
WHERE cm.user_id = ? AND c.teacher_id = ?
`).get(student.id, req.user.id);
if (!inClass) return res.status(403).json({ error: 'Ученик не входит ни в один из ваших классов' });
const linked = inClass ? null : db.prepare(
'SELECT 1 FROM teacher_students WHERE teacher_id=? AND student_id=?'
).get(req.user.id, student.id);
if (!inClass && !linked) {
return res.status(403).json({
error: 'Ученик не входит в ваши классы и не добавлен в «Мои ученики». Добавьте его на странице «Мои ученики».',
});
}
}
test_id = test_id ? Number(test_id) : null;
+13 -7
View File
@@ -355,14 +355,20 @@ function listStudents(req, res) {
).all();
return res.json(rows);
}
// Teacher: only students in their classes
// Teacher: students in their classes + personal students (teacher_students)
const rows = db.prepare(`
SELECT DISTINCT u.id, u.name, u.email FROM users u
JOIN class_members cm ON cm.user_id = u.id
JOIN classes c ON c.id = cm.class_id
WHERE c.teacher_id = ? AND u.role IN ('student','free_student')
ORDER BY u.name
`).all(req.user.id);
SELECT id, name, email FROM (
SELECT DISTINCT u.id, u.name, u.email FROM users u
JOIN class_members cm ON cm.user_id = u.id
JOIN classes c ON c.id = cm.class_id
WHERE c.teacher_id = ? AND u.role IN ('student','free_student')
UNION
SELECT u.id, u.name, u.email FROM users u
JOIN teacher_students ts ON ts.student_id = u.id
WHERE ts.teacher_id = ? AND u.role IN ('student','free_student')
)
ORDER BY name
`).all(req.user.id, req.user.id);
res.json(rows);
}
@@ -0,0 +1,9 @@
-- Personal student list for tutoring scenarios (student doesn't need to be in any class)
CREATE TABLE teacher_students (
teacher_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
note TEXT NOT NULL DEFAULT '',
PRIMARY KEY (teacher_id, student_id)
);
CREATE INDEX idx_teacher_students_student ON teacher_students(student_id);
+81
View File
@@ -0,0 +1,81 @@
'use strict';
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { emit } = require('../sse');
router.use(authMiddleware);
router.use(requireRole('teacher', 'admin'));
/* GET /api/teacher-students — my linked students (not in classes, can be assigned to) */
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT u.id, u.name, u.email, ts.added_at, ts.note,
(SELECT COUNT(*) FROM assignments WHERE user_id = u.id AND created_by = ?) AS assignment_count
FROM teacher_students ts
JOIN users u ON u.id = ts.student_id
WHERE ts.teacher_id = ?
ORDER BY u.name
`).all(req.user.id, req.user.id);
res.json({ students: rows });
});
/* POST /api/teacher-students — add by email
body: { email: string, note?: string } */
router.post('/', (req, res) => {
const email = (req.body?.email || '').trim().toLowerCase();
const note = String(req.body?.note || '').slice(0, 200);
if (!email) return res.status(400).json({ error: 'email обязателен' });
const student = db.prepare(
"SELECT id, name, email FROM users WHERE email = ? AND role IN ('student','free_student')"
).get(email);
if (!student) return res.status(404).json({ error: 'Ученик с таким email не зарегистрирован' });
// Can't add yourself
if (student.id === req.user.id) return res.status(400).json({ error: 'Нельзя добавить себя' });
// Check if already added
const existing = db.prepare(
'SELECT 1 FROM teacher_students WHERE teacher_id=? AND student_id=?'
).get(req.user.id, student.id);
if (existing) return res.status(409).json({ error: 'Ученик уже добавлен в ваш список' });
db.prepare(
'INSERT INTO teacher_students (teacher_id, student_id, note) VALUES (?, ?, ?)'
).run(req.user.id, student.id, note);
// Notify student
try {
emit(student.id, {
type: 'notification', notif_type: 'teacher_added',
message: `${req.user.name || 'Учитель'} добавил вас как своего ученика`,
link: '/profile',
});
} catch {}
res.status(201).json({ ok: true, student });
});
/* PATCH /api/teacher-students/:student_id — update note */
router.patch('/:student_id', (req, res) => {
const sid = Number(req.params.student_id);
const link = db.prepare('SELECT 1 FROM teacher_students WHERE teacher_id=? AND student_id=?').get(req.user.id, sid);
if (!link) return res.status(404).json({ error: 'Связь не найдена' });
const note = String(req.body?.note || '').slice(0, 200);
db.prepare('UPDATE teacher_students SET note=? WHERE teacher_id=? AND student_id=?')
.run(note, req.user.id, sid);
res.json({ ok: true });
});
/* DELETE /api/teacher-students/:student_id */
router.delete('/:student_id', (req, res) => {
const sid = Number(req.params.student_id);
const r = db.prepare(
'DELETE FROM teacher_students WHERE teacher_id=? AND student_id=?'
).run(req.user.id, sid);
if (r.changes === 0) return res.status(404).json({ error: 'Связь не найдена' });
res.json({ ok: true });
});
module.exports = router;
+2
View File
@@ -52,6 +52,7 @@ const redBookRoutes = require('./routes/red-book');
const parentRoutes = require('./routes/parent');
const exam9Routes = require('./routes/exam9');
const textbookRoutes = require('./routes/textbooks');
const teacherStudentsRoutes = require('./routes/teacherStudents');
const { requestId, errorHandler } = require('./middleware/errorHandler');
@@ -170,6 +171,7 @@ app.use('/api/biochem', require('./routes/biochem'));
app.use('/api/parent', parentRoutes);
app.use('/api/exam9', exam9Routes);
app.use('/api/textbooks', textbookRoutes);
app.use('/api/teacher-students', teacherStudentsRoutes);
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
const _featDb = require('./db/db');