Add worksheet short-answer grading view, student inbox, and parent-student messaging

- Grading queue now shows short-answer questions and student responses inline so the parent can read answers before scoring
- Student inbox tab shows all graded items with score, percentage, and parent feedback; unread badge appears on login
- Parent-student messaging: students send messages from the Inbox tab; Messages panel in admin shows threads with unread count badge, full conversation history, and reply compose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-20 13:28:28 -04:00
1 parent 2834738598
commit 556b570b60
7 files changed
+353 -2

No files matched your search

+9
View File
@@ -206,6 +206,15 @@ db.exec(`
answers TEXT, answers TEXT,
completed_at TEXT NOT NULL DEFAULT (datetime('now')) completed_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE,
sender TEXT NOT NULL,
body TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT (datetime('now')),
read_at TEXT
);
`); `);
function tryAlter(sql) { function tryAlter(sql) {
+12
View File
@@ -28,6 +28,7 @@
<button class="nav-pill" data-panel="courses">📚 Courses</button> <button class="nav-pill" data-panel="courses">📚 Courses</button>
<button class="nav-pill" data-panel="planner">🗓️ Planner</button> <button class="nav-pill" data-panel="planner">🗓️ Planner</button>
<button class="nav-pill" data-panel="grading">✅ Grading</button> <button class="nav-pill" data-panel="grading">✅ Grading</button>
<button class="nav-pill" data-panel="messages">💬 Messages</button>
<button class="nav-pill" data-panel="gradebook">📊 Gradebook</button> <button class="nav-pill" data-panel="gradebook">📊 Gradebook</button>
<button class="nav-pill" data-panel="spelling">✏️ Spelling</button> <button class="nav-pill" data-panel="spelling">✏️ Spelling</button>
<button class="nav-pill" data-panel="decks">🗂️ Flashcards</button> <button class="nav-pill" data-panel="decks">🗂️ Flashcards</button>
@@ -376,6 +377,17 @@
</div> </div>
</section> </section>
<!-- Messages -->
<section id="panel-messages" class="panel">
<h1>Messages</h1>
<div class="msg-admin-cols">
<div id="msg-thread-list" class="msg-thread-list"></div>
<div id="msg-admin-thread" class="msg-admin-thread">
<p class="hint" style="padding:1rem">Select a student to view the conversation.</p>
</div>
</div>
</section>
<!-- Gradebook --> <!-- Gradebook -->
<section id="panel-gradebook" class="panel"> <section id="panel-gradebook" class="panel">
<h1>Gradebook</h1> <h1>Gradebook</h1>
+115 -1
View File
@@ -78,6 +78,7 @@ async function unlock() {
$('#console').hidden = false; $('#console').hidden = false;
showPanel('kids'); showPanel('kids');
loadGradingBadge(); loadGradingBadge();
loadMessagesBadge();
} }
async function loadGradingBadge() { async function loadGradingBadge() {
@@ -110,7 +111,7 @@ function showPanel(name) {
document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name)); document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name));
const loaders = { const loaders = {
kids: loadKids, courses: loadCourses, planner: loadPlannerPanel, kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
grading: loadGrading, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks, grading: loadGrading, messages: loadMessages, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
settings: loadSettings, settings: loadSettings,
}; };
if (loaders[name]) loaders[name](); if (loaders[name]) loaders[name]();
@@ -1863,6 +1864,26 @@ async function loadGrading() {
const rows = filterEl.value ? allRows.filter((r) => String(r.studentId) === filterEl.value) : allRows; const rows = filterEl.value ? allRows.filter((r) => String(r.studentId) === filterEl.value) : allRows;
function renderShortAnswerBlock(r) {
if (r.itemType !== 'worksheet' || !r.body || !r.answers) return '';
let wd, answers;
try { wd = JSON.parse(r.body); } catch { return ''; }
try { answers = JSON.parse(r.answers); } catch { answers = {}; }
const rows = [];
(wd.sections || []).forEach((section, si) => {
(section.questions || []).forEach((q, qi) => {
if (q.type !== 'short') return;
const response = (answers[`${si}:${qi}`] || '').trim();
rows.push(`<div class="sa-row">
<div class="sa-question">${esc(q.text)}</div>
<div class="sa-response">${response ? esc(response) : '<em class="hint">No answer written</em>'}</div>
</div>`);
});
});
if (!rows.length) return '';
return `<div class="sa-block"><strong style="font-size:.85rem;color:var(--muted)">Short answers</strong>${rows.join('')}</div>`;
}
$('#grading-empty').hidden = rows.length > 0; $('#grading-empty').hidden = rows.length > 0;
$('#grading-rows').innerHTML = rows $('#grading-rows').innerHTML = rows
.map((r) => { .map((r) => {
@@ -1879,6 +1900,7 @@ async function loadGrading() {
<span class="hint">${esc(r.courseName)} · ${esc(r.unitName)}</span> <span class="hint">${esc(r.courseName)} · ${esc(r.unitName)}</span>
${noteTag} ${noteTag}
</div> </div>
${renderShortAnswerBlock(r)}
${evidenceBtn} ${evidenceBtn}
<div class="grading-footer"> <div class="grading-footer">
<div class="grade-score-row"> <div class="grade-score-row">
@@ -1927,6 +1949,98 @@ async function loadGrading() {
$('#grading-student-filter').addEventListener('change', loadGrading); $('#grading-student-filter').addEventListener('change', loadGrading);
// ============================================================
// Messages
// ============================================================
async function loadMessagesBadge() {
try {
const { count } = await api('/api/admin/messages/count');
const pill = document.querySelector('.nav-pill[data-panel="messages"]');
if (!pill) return;
let badge = pill.querySelector('.msg-nav-badge');
if (count > 0) {
if (!badge) { badge = document.createElement('span'); badge.className = 'msg-nav-badge'; pill.appendChild(badge); }
badge.textContent = count;
pill.classList.add('grading-pending');
} else {
badge?.remove();
pill.classList.remove('grading-pending');
}
} catch { /* non-critical */ }
}
let activeMessageStudentId = null;
async function loadMessages() {
const threads = await api('/api/admin/messages');
const listEl = $('#msg-thread-list');
if (!threads.length) {
listEl.innerHTML = '<p class="hint" style="padding:1rem">No messages yet.</p>';
return;
}
listEl.innerHTML = threads.map((t) => `
<button class="msg-student-btn${t.unread > 0 ? ' msg-has-unread' : ''}" data-student-id="${t.studentId}">
<span class="msg-student-avatar">${esc(t.emoji)}</span>
<span class="msg-student-info">
<strong>${esc(t.studentName)}</strong>
${t.unread > 0 ? `<span class="feedback-badge">${t.unread} new</span>` : ''}
<span class="msg-preview">${esc((t.lastBody || '').slice(0, 60))}</span>
</span>
</button>
`).join('');
listEl.querySelectorAll('[data-student-id]').forEach((btn) =>
btn.addEventListener('click', () => openAdminThread(Number(btn.dataset.studentId), threads.find((t) => t.studentId === Number(btn.dataset.studentId))))
);
if (activeMessageStudentId) openAdminThread(activeMessageStudentId, threads.find((t) => t.studentId === activeMessageStudentId));
}
async function openAdminThread(studentId, meta) {
activeMessageStudentId = studentId;
const msgs = await api(`/api/admin/messages/${studentId}`);
loadMessagesBadge();
const threadEl = $('#msg-admin-thread');
const name = meta ? `${esc(meta.emoji)} ${esc(meta.studentName)}` : 'Student';
threadEl.innerHTML = `
<div class="msg-admin-thread-header"><strong>${name}</strong></div>
<div class="msg-admin-msgs" id="msg-admin-msgs">${renderAdminMsgs(msgs)}</div>
<form class="msg-admin-compose" id="msg-admin-compose">
<input class="comment-input" id="msg-admin-input" placeholder="Reply to ${meta?.studentName || 'student'}…">
<button type="submit">Send</button>
</form>
`;
const msgsEl = $('#msg-admin-msgs');
msgsEl.scrollTop = msgsEl.scrollHeight;
$('#msg-admin-compose').addEventListener('submit', async (e) => {
e.preventDefault();
const input = $('#msg-admin-input');
const body = input.value.trim();
if (!body) return;
const lastMsgId = msgs[msgs.length - 1]?.id;
if (!lastMsgId) return;
input.value = '';
await api(`/api/messages/${lastMsgId}/reply`, { method: 'POST', body: { body } });
const updated = await api(`/api/admin/messages/${studentId}`);
msgsEl.innerHTML = renderAdminMsgs(updated);
msgsEl.scrollTop = msgsEl.scrollHeight;
loadMessagesBadge();
});
}
function renderAdminMsgs(msgs) {
if (!msgs.length) return '<p class="hint" style="padding:.75rem;text-align:center">No messages yet.</p>';
return msgs.map((m) => {
const isParent = m.sender === 'parent';
const time = new Date(m.sent_at + 'Z').toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
return `<div class="msg-bubble-wrap ${isParent ? 'msg-mine' : 'msg-theirs'}">
<div class="msg-bubble">${esc(m.body)}</div>
<div class="msg-time">${time}</div>
</div>`;
}).join('');
}
// ============================================================ // ============================================================
// Gradebook // Gradebook
// ============================================================ // ============================================================
+87 -1
View File
@@ -304,7 +304,7 @@ document.querySelectorAll('.nav-pill').forEach((pill) =>
pill.addEventListener('click', () => switchTab(pill.dataset.tab)) pill.addEventListener('click', () => switchTab(pill.dataset.tab))
); );
const TAB_ORDER = ['today', 'courses', 'spelling']; const TAB_ORDER = ['today', 'courses', 'spelling', 'inbox'];
let currentTab = 'today'; let currentTab = 'today';
function switchTab(name) { function switchTab(name) {
@@ -321,6 +321,91 @@ function switchTab(name) {
panel.classList.add(dir === 'right' ? 'sliding-right' : 'sliding-left'); panel.classList.add(dir === 'right' ? 'sliding-right' : 'sliding-left');
} }
}); });
if (name === 'inbox') loadInbox();
}
function inboxSeenKey(studentId) { return `inbox_seen_${studentId}`; }
function getSeenIds(studentId) {
try { return new Set(JSON.parse(localStorage.getItem(inboxSeenKey(studentId)) || '[]')); } catch { return new Set(); }
}
function markAllSeen(studentId, ids) {
localStorage.setItem(inboxSeenKey(studentId), JSON.stringify([...ids]));
}
async function loadInbox() {
const sid = currentStudent?.id;
if (!sid) return;
const [items, msgs] = await Promise.all([
api(`/api/students/${sid}/inbox`),
api(`/api/students/${sid}/messages`),
]);
const seen = getSeenIds(sid);
$('#inbox-empty').hidden = items.length > 0;
$('#inbox-list').innerHTML = items.map((r) => {
const pct = r.points_possible ? Math.round((r.score / r.points_possible) * 100) : null;
const isNew = !seen.has(r.submissionId);
const scoreClass = pct !== null ? (pct >= 80 ? 'score-good' : 'score-bad') : '';
const date = new Date(r.graded_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return `<div class="inbox-card${isNew ? ' inbox-new' : ''}">
<div class="inbox-card-header">
<span class="inbox-item-title">${esc(r.itemTitle)}</span>
${isNew ? '<span class="feedback-badge">New!</span>' : ''}
</div>
<div class="inbox-meta">${esc(r.courseName)} · ${esc(r.unitName)} · ${date}</div>
${r.points_possible !== null ? `<div class="inbox-score ${scoreClass}">🌟 ${r.score} / ${r.points_possible}${pct !== null ? ` (${pct}%)` : ''}</div>` : ''}
${r.parent_comment ? `<div class="parent-comment">💬 ${esc(r.parent_comment)}</div>` : ''}
</div>`;
}).join('');
markAllSeen(sid, items.map((r) => r.submissionId));
updateInboxBadge(sid, 0);
renderMsgThread(msgs);
}
function renderMsgThread(msgs) {
const thread = $('#msg-thread');
if (!thread) return;
if (!msgs.length) {
thread.innerHTML = '<p class="hint" style="text-align:center;padding:.75rem">No messages yet. Say hi to your parent!</p>';
return;
}
thread.innerHTML = msgs.map((m) => {
const isStudent = m.sender === 'student';
const time = new Date(m.sent_at + 'Z').toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
return `<div class="msg-bubble-wrap ${isStudent ? 'msg-mine' : 'msg-theirs'}">
<div class="msg-bubble">${esc(m.body)}</div>
<div class="msg-time">${time}</div>
</div>`;
}).join('');
thread.scrollTop = thread.scrollHeight;
}
$('#msg-compose')?.addEventListener('submit', async (e) => {
e.preventDefault();
const input = $('#msg-input');
const body = input.value.trim();
if (!body || !currentStudent) return;
input.value = '';
await api(`/api/students/${currentStudent.id}/messages`, { method: 'POST', body: { body } });
const msgs = await api(`/api/students/${currentStudent.id}/messages`);
renderMsgThread(msgs);
});
function updateInboxBadge(studentId, count) {
const badge = $('#inbox-badge');
if (!badge) return;
badge.hidden = count === 0;
badge.textContent = count;
}
async function refreshInboxBadge(studentId) {
try {
const items = await api(`/api/students/${studentId}/inbox`);
const seen = getSeenIds(studentId);
const unseen = items.filter((r) => !seen.has(r.submissionId)).length;
updateInboxBadge(studentId, unseen);
} catch { /* non-critical */ }
} }
async function openKid(id, tab = 'today', welcome = false) { async function openKid(id, tab = 'today', welcome = false) {
@@ -330,6 +415,7 @@ async function openKid(id, tab = 'today', welcome = false) {
api(`/api/courses/mine/${id}`), api(`/api/courses/mine/${id}`),
api(`/api/students/${id}/tests`), api(`/api/students/${id}/tests`),
]); ]);
refreshInboxBadge(id);
currentStudent = state.student; currentStudent = state.student;
youngLearnerMode = !!currentStudent.young_learner; youngLearnerMode = !!currentStudent.young_learner;
applyTheme(currentStudent.theme || 'blue'); applyTheme(currentStudent.theme || 'blue');
+15
View File
@@ -78,6 +78,7 @@
<button class="nav-pill active" data-tab="today">📋 Today</button> <button class="nav-pill active" data-tab="today">📋 Today</button>
<button class="nav-pill" data-tab="courses">📚 My Courses</button> <button class="nav-pill" data-tab="courses">📚 My Courses</button>
<button class="nav-pill" data-tab="spelling">✏️ Spelling</button> <button class="nav-pill" data-tab="spelling">✏️ Spelling</button>
<button class="nav-pill" data-tab="inbox">📬 Inbox<span id="inbox-badge" class="feedback-badge" hidden></span></button>
</div> </div>
<div id="tab-today" class="tab-panel"> <div id="tab-today" class="tab-panel">
@@ -99,6 +100,20 @@
</div> </div>
<div id="kid-test-history"></div> <div id="kid-test-history"></div>
</div> </div>
<div id="tab-inbox" class="tab-panel" hidden>
<div id="inbox-list"></div>
<p id="inbox-empty" class="hint" hidden>No grades yet — check back after your parent reviews your work!</p>
<div class="msg-thread-wrap">
<h2 class="msg-thread-title">💬 Messages</h2>
<div id="msg-thread" class="msg-thread"></div>
<form id="msg-compose" class="msg-compose">
<textarea id="msg-input" class="msg-input" placeholder="Write a message to your parent…" rows="2"></textarea>
<button type="submit" class="msg-send-btn">Send ➤</button>
</form>
</div>
</div>
</section> </section>
<!-- All-done celebration overlay --> <!-- All-done celebration overlay -->
+44
View File
@@ -987,6 +987,11 @@ td.overdue, .overdue { background: var(--bad-bg) !important; }
.grading-footer { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.6rem; } .grading-footer { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.6rem; }
.comment-input { flex: 1; min-width: 140px; padding: 0.4rem 0.6rem; border: 1.5px solid #d8d2c0; border-radius: 8px; font-family: inherit; } .comment-input { flex: 1; min-width: 140px; padding: 0.4rem 0.6rem; border: 1.5px solid #d8d2c0; border-radius: 8px; font-family: inherit; }
.badge-tag { background: var(--gold); color: #333; border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.8rem; font-weight: 600; } .badge-tag { background: var(--gold); color: #333; border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.8rem; font-weight: 600; }
.sa-block { background: var(--bg); border: 1px solid #e2dccf; border-radius: 10px; padding: 0.75rem; margin: 0.5rem 0; display: flex; flex-direction: column; gap: 0.6rem; }
.sa-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.sa-question { font-size: 0.9rem; color: var(--muted); }
.sa-response { font-size: 0.95rem; font-weight: 600; color: var(--ink); white-space: pre-wrap; }
@media (max-width: 600px) { .sa-row { grid-template-columns: 1fr; } }
/* ---------- themes ---------- */ /* ---------- themes ---------- */
body.theme-green { --accent: #2e9e5b; --accent-dark: #247a47; --bg: #f0faf4; } body.theme-green { --accent: #2e9e5b; --accent-dark: #247a47; --bg: #f0faf4; }
@@ -1246,6 +1251,45 @@ body.bg-bubbles {
border-left: 4px solid var(--good); border-left: 4px solid var(--good);
font-size: 1.05rem; font-size: 1.05rem;
} }
.inbox-card { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; padding: 1rem; margin-bottom: 0.75rem; }
.inbox-card.inbox-new { border-color: var(--accent); }
.inbox-card-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; }
.inbox-item-title { font-weight: 700; font-size: 1.05rem; flex: 1; }
.inbox-meta { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.4rem; }
.inbox-score { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.25rem; }
.inbox-score.score-good { color: var(--good); }
.inbox-score.score-bad { color: var(--bad); }
/* ---------- messaging ---------- */
.msg-thread-wrap { margin-top: 1.5rem; background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; overflow: hidden; }
.msg-thread-title { margin: 0; padding: .75rem 1rem; font-size: 1rem; border-bottom: 1px solid #e2dccf; }
.msg-thread { max-height: 320px; overflow-y: auto; padding: .75rem 1rem; display: flex; flex-direction: column; gap: .5rem; }
.msg-compose { display: flex; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid #e2dccf; }
.msg-input { flex: 1; padding: .5rem .75rem; border: 1.5px solid #d8d2c0; border-radius: 10px; font-family: inherit; font-size: .95rem; resize: none; }
.msg-send-btn { padding: .5rem 1rem; border-radius: 10px; background: var(--accent); color: #fff; border: none; font-size: .9rem; cursor: pointer; white-space: nowrap; }
.msg-bubble-wrap { display: flex; flex-direction: column; max-width: 75%; }
.msg-mine { align-self: flex-end; align-items: flex-end; }
.msg-theirs { align-self: flex-start; align-items: flex-start; }
.msg-bubble { padding: .55rem .85rem; border-radius: 16px; font-size: .95rem; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
.msg-mine .msg-bubble { background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
.msg-theirs .msg-bubble { background: #e8e2d4; color: var(--ink); border-bottom-left-radius: 4px; }
.msg-time { font-size: .72rem; color: var(--muted); margin-top: .2rem; }
/* admin messages panel */
.msg-admin-cols { display: grid; grid-template-columns: 240px 1fr; gap: 1rem; height: calc(100vh - 180px); }
.msg-thread-list { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 12px; overflow-y: auto; display: flex; flex-direction: column; }
.msg-student-btn { background: none; border: none; border-bottom: 1px solid #e2dccf; padding: .75rem; text-align: left; cursor: pointer; display: flex; gap: .6rem; align-items: flex-start; }
.msg-student-btn:hover, .msg-student-btn.active { background: var(--accent-light, #e8f0fe); }
.msg-student-btn.msg-has-unread { font-weight: 700; }
.msg-student-avatar { font-size: 1.5rem; line-height: 1; }
.msg-student-info { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.msg-preview { font-size: .8rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; font-weight: 400; }
.msg-nav-badge { background: var(--bad); color: #fff; border-radius: 999px; font-size: .72rem; font-weight: 800; padding: .1rem .45rem; margin-left: .35rem; }
.msg-admin-thread { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; }
.msg-admin-thread-header { padding: .75rem 1rem; border-bottom: 1px solid #e2dccf; font-size: 1rem; }
.msg-admin-msgs { flex: 1; overflow-y: auto; padding: .75rem 1rem; display: flex; flex-direction: column; gap: .5rem; }
.msg-admin-compose { display: flex; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid #e2dccf; }
@media (max-width: 640px) { .msg-admin-cols { grid-template-columns: 1fr; height: auto; } }
/* ---------- quiz / matching instructions ---------- */ /* ---------- quiz / matching instructions ---------- */
.quiz-instructions { .quiz-instructions {
+71
View File
@@ -1727,6 +1727,7 @@ app.get('/api/grading-queue', requirePin, (req, res) => {
res.json(db.prepare(` res.json(db.prepare(`
SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible, SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible,
sub.evidence_notes, sub.evidence_photo, sub.student_note, sub.evidence_notes, sub.evidence_photo, sub.student_note,
sub.answers, i.body,
s.id AS studentId, s.name AS studentName, s.emoji, s.id AS studentId, s.name AS studentName, s.emoji,
i.id AS itemId, i.title AS itemTitle, i.type AS itemType, i.id AS itemId, i.title AS itemTitle, i.type AS itemType,
c.name AS courseName, u.name AS unitName c.name AS courseName, u.name AS unitName
@@ -1754,6 +1755,76 @@ app.put('/api/submissions/:id/grade', requirePin, (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// ---------- messages ----------
app.get('/api/students/:id/messages', (req, res) => {
const msgs = db.prepare(`SELECT id, sender, body, sent_at, read_at FROM messages WHERE student_id = ? ORDER BY sent_at`).all(req.params.id);
// Mark parent messages as read when student loads the thread
db.prepare(`UPDATE messages SET read_at = datetime('now') WHERE student_id = ? AND sender = 'parent' AND read_at IS NULL`).run(req.params.id);
res.json(msgs);
});
app.post('/api/students/:id/messages', (req, res) => {
const body = String(req.body.body || '').trim();
if (!body) return res.status(400).json({ error: 'Message cannot be empty' });
const student = db.prepare(`SELECT id FROM students WHERE id = ?`).get(req.params.id);
if (!student) return res.status(404).json({ error: 'No such student' });
const { lastInsertRowid } = db.prepare(`INSERT INTO messages (student_id, sender, body) VALUES (?, 'student', ?)`).run(req.params.id, body);
res.json({ id: lastInsertRowid });
});
app.post('/api/messages/:id/reply', requirePin, (req, res) => {
const body = String(req.body.body || '').trim();
if (!body) return res.status(400).json({ error: 'Reply cannot be empty' });
const msg = db.prepare(`SELECT student_id FROM messages WHERE id = ?`).get(req.params.id);
if (!msg) return res.status(404).json({ error: 'No such message' });
const { lastInsertRowid } = db.prepare(`INSERT INTO messages (student_id, sender, body) VALUES (?, 'parent', ?)`).run(msg.student_id, body);
db.prepare(`UPDATE messages SET read_at = datetime('now') WHERE id = ?`).run(req.params.id);
res.json({ id: lastInsertRowid });
});
app.get('/api/admin/messages', requirePin, (req, res) => {
const threads = db.prepare(`
SELECT s.id AS studentId, s.name AS studentName, s.emoji,
COUNT(CASE WHEN m.sender = 'student' AND m.read_at IS NULL THEN 1 END) AS unread,
MAX(m.sent_at) AS lastAt,
(SELECT body FROM messages WHERE student_id = s.id ORDER BY sent_at DESC LIMIT 1) AS lastBody
FROM students s
JOIN messages m ON m.student_id = s.id
GROUP BY s.id
ORDER BY lastAt DESC
`).all();
res.json(threads);
});
app.get('/api/admin/messages/count', requirePin, (req, res) => {
const { n } = db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE sender = 'student' AND read_at IS NULL`).get();
res.json({ count: n });
});
app.get('/api/admin/messages/:studentId', requirePin, (req, res) => {
const msgs = db.prepare(`SELECT id, sender, body, sent_at, read_at FROM messages WHERE student_id = ? ORDER BY sent_at`).all(req.params.studentId);
db.prepare(`UPDATE messages SET read_at = datetime('now') WHERE student_id = ? AND sender = 'student' AND read_at IS NULL`).run(req.params.studentId);
res.json(msgs);
});
// ---------- student inbox ----------
app.get('/api/students/:id/inbox', (req, res) => {
const items = db.prepare(`
SELECT sub.id AS submissionId, sub.score, sub.points_possible, sub.graded_at, sub.parent_comment,
i.title AS itemTitle, c.name AS courseName, u.name AS unitName
FROM submissions sub
JOIN items i ON i.id = sub.item_id
JOIN units u ON u.id = i.unit_id
JOIN courses c ON c.id = u.course_id
WHERE sub.student_id = ? AND sub.status = 'graded' AND sub.graded_at IS NOT NULL
ORDER BY sub.graded_at DESC
LIMIT 50
`).all(req.params.id);
res.json(items);
});
// ---------- gradebook (parent) ---------- // ---------- gradebook (parent) ----------
app.get('/api/gradebook/:courseId', requirePin, (req, res) => { app.get('/api/gradebook/:courseId', requirePin, (req, res) => {