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:
1 parent
2834738598
commit
556b570b60
7 files changed
+353
-2
No files matched your search
@@ -206,6 +206,15 @@ db.exec(`
|
||||
answers TEXT,
|
||||
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) {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<button class="nav-pill" data-panel="courses">📚 Courses</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="messages">💬 Messages</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="decks">🗂️ Flashcards</button>
|
||||
@@ -376,6 +377,17 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<section id="panel-gradebook" class="panel">
|
||||
<h1>Gradebook</h1>
|
||||
|
||||
+115
-1
@@ -78,6 +78,7 @@ async function unlock() {
|
||||
$('#console').hidden = false;
|
||||
showPanel('kids');
|
||||
loadGradingBadge();
|
||||
loadMessagesBadge();
|
||||
}
|
||||
|
||||
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));
|
||||
const loaders = {
|
||||
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,
|
||||
};
|
||||
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;
|
||||
|
||||
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-rows').innerHTML = rows
|
||||
.map((r) => {
|
||||
@@ -1879,6 +1900,7 @@ async function loadGrading() {
|
||||
<span class="hint">${esc(r.courseName)} · ${esc(r.unitName)}</span>
|
||||
${noteTag}
|
||||
</div>
|
||||
${renderShortAnswerBlock(r)}
|
||||
${evidenceBtn}
|
||||
<div class="grading-footer">
|
||||
<div class="grade-score-row">
|
||||
@@ -1927,6 +1949,98 @@ async function 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
|
||||
// ============================================================
|
||||
|
||||
+87
-1
@@ -304,7 +304,7 @@ document.querySelectorAll('.nav-pill').forEach((pill) =>
|
||||
pill.addEventListener('click', () => switchTab(pill.dataset.tab))
|
||||
);
|
||||
|
||||
const TAB_ORDER = ['today', 'courses', 'spelling'];
|
||||
const TAB_ORDER = ['today', 'courses', 'spelling', 'inbox'];
|
||||
let currentTab = 'today';
|
||||
|
||||
function switchTab(name) {
|
||||
@@ -321,6 +321,91 @@ function switchTab(name) {
|
||||
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) {
|
||||
@@ -330,6 +415,7 @@ async function openKid(id, tab = 'today', welcome = false) {
|
||||
api(`/api/courses/mine/${id}`),
|
||||
api(`/api/students/${id}/tests`),
|
||||
]);
|
||||
refreshInboxBadge(id);
|
||||
currentStudent = state.student;
|
||||
youngLearnerMode = !!currentStudent.young_learner;
|
||||
applyTheme(currentStudent.theme || 'blue');
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
<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="spelling">✏️ Spelling</button>
|
||||
<button class="nav-pill" data-tab="inbox">📬 Inbox<span id="inbox-badge" class="feedback-badge" hidden></span></button>
|
||||
</div>
|
||||
|
||||
<div id="tab-today" class="tab-panel">
|
||||
@@ -99,6 +100,20 @@
|
||||
</div>
|
||||
<div id="kid-test-history"></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>
|
||||
|
||||
<!-- All-done celebration overlay -->
|
||||
|
||||
@@ -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; }
|
||||
.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; }
|
||||
.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 ---------- */
|
||||
body.theme-green { --accent: #2e9e5b; --accent-dark: #247a47; --bg: #f0faf4; }
|
||||
@@ -1246,6 +1251,45 @@ body.bg-bubbles {
|
||||
border-left: 4px solid var(--good);
|
||||
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-instructions {
|
||||
|
||||
@@ -1727,6 +1727,7 @@ app.get('/api/grading-queue', requirePin, (req, res) => {
|
||||
res.json(db.prepare(`
|
||||
SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible,
|
||||
sub.evidence_notes, sub.evidence_photo, sub.student_note,
|
||||
sub.answers, i.body,
|
||||
s.id AS studentId, s.name AS studentName, s.emoji,
|
||||
i.id AS itemId, i.title AS itemTitle, i.type AS itemType,
|
||||
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 });
|
||||
});
|
||||
|
||||
// ---------- 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) ----------
|
||||
|
||||
app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
|
||||
|
||||
Reference in new issue
Block a user