Skip to content

Commit a1c5071

Browse files
dsshimelclaude
andcommitted
add Refresh Messages button to re-fetch edited Discord messages
Instructors can now click "Refresh Messages" on the Students page to re-fetch all #attendance and #eod messages from Discord since the cohort start date, updating any edited messages via the existing upsert. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8f6b5e7 commit a1c5071

File tree

4 files changed

+232
-0
lines changed

4 files changed

+232
-0
lines changed

attendabot/backend/src/api/routes/students.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import {
1717
getStudentImage,
1818
updateStudentImage,
1919
deleteStudentImage,
20+
logMessage,
2021
} from "../../services/db";
22+
import { fetchTextChannel, fetchMessagesSince } from "../../services/discord";
23+
import { MONITORED_CHANNEL_IDS } from "../../bot/constants";
2124

2225
/** Router for student endpoints. */
2326
export const studentsRouter = Router();
@@ -316,3 +319,54 @@ studentsRouter.delete("/:id/image", (req: AuthRequest, res: Response) => {
316319
res.status(500).json({ error: "Failed to delete student image" });
317320
}
318321
});
322+
323+
/** POST /api/cohorts/:cohortId/refresh-messages - Re-fetch Discord messages since cohort start date. */
324+
cohortsRouter.post("/:cohortId/refresh-messages", async (req: AuthRequest, res: Response) => {
325+
try {
326+
const cohortId = parseInt(req.params.cohortId, 10);
327+
if (isNaN(cohortId)) {
328+
res.status(400).json({ error: "Invalid cohort ID" });
329+
return;
330+
}
331+
332+
const cohorts = getCohorts();
333+
const cohort = cohorts.find((c) => c.id === cohortId);
334+
if (!cohort) {
335+
res.status(404).json({ error: "Cohort not found" });
336+
return;
337+
}
338+
339+
if (!cohort.start_date) {
340+
res.status(400).json({ error: "Cohort has no start date" });
341+
return;
342+
}
343+
344+
const since = new Date(cohort.start_date);
345+
let messagesProcessed = 0;
346+
347+
for (const channelId of MONITORED_CHANNEL_IDS) {
348+
const channel = await fetchTextChannel(channelId);
349+
const messages = await fetchMessagesSince(channel, since);
350+
351+
for (const message of messages) {
352+
logMessage({
353+
discord_message_id: message.id,
354+
channel_id: message.channelId,
355+
channel_name: channel.name,
356+
author_id: message.author.id,
357+
display_name: message.member?.displayName || null,
358+
username: message.author.username,
359+
content: message.content,
360+
created_at: message.createdAt.toISOString(),
361+
});
362+
messagesProcessed++;
363+
}
364+
}
365+
366+
console.log(`Refreshed ${messagesProcessed} messages for cohort ${cohort.name}`);
367+
res.json({ success: true, messagesProcessed });
368+
} catch (error) {
369+
console.error("Error refreshing messages:", error);
370+
res.status(500).json({ error: "Failed to refresh messages" });
371+
}
372+
});

attendabot/backend/src/test/api/students.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,3 +546,135 @@ describe("Cohort API - Database Operations", () => {
546546
});
547547
});
548548
});
549+
550+
describe("Refresh Messages - Validation Logic", () => {
551+
let db: Database.Database;
552+
553+
beforeEach(() => {
554+
db = createTestDatabase();
555+
});
556+
557+
afterEach(() => {
558+
db.close();
559+
});
560+
561+
describe("cohortId parameter validation", () => {
562+
it("rejects non-numeric cohort ID", () => {
563+
const parsed = parseInt("abc", 10);
564+
expect(isNaN(parsed)).toBe(true);
565+
});
566+
567+
it("accepts valid numeric cohort ID", () => {
568+
const parsed = parseInt("5", 10);
569+
expect(isNaN(parsed)).toBe(false);
570+
expect(parsed).toBe(5);
571+
});
572+
});
573+
574+
describe("cohort lookup", () => {
575+
it("returns undefined for non-existent cohort", () => {
576+
const stmt = db.prepare("SELECT * FROM cohorts WHERE id = ?");
577+
const cohort = stmt.get(9999);
578+
expect(cohort).toBeUndefined();
579+
});
580+
581+
it("finds existing cohort by ID", () => {
582+
const created = createTestCohort(db, "Sp2026");
583+
584+
const stmt = db.prepare("SELECT * FROM cohorts WHERE id = ?");
585+
const cohort = stmt.get(created.id) as { id: number; name: string };
586+
587+
expect(cohort).toBeDefined();
588+
expect(cohort.name).toBe("Sp2026");
589+
});
590+
});
591+
592+
describe("start_date validation", () => {
593+
it("rejects cohort with no start_date", () => {
594+
const cohort = createTestCohort(db, "NoDate");
595+
596+
const stmt = db.prepare("SELECT start_date FROM cohorts WHERE id = ?");
597+
const row = stmt.get(cohort.id) as { start_date: string | null };
598+
599+
expect(row.start_date).toBeNull();
600+
});
601+
602+
it("accepts cohort with start_date", () => {
603+
const cohort = createTestCohort(db, "HasDate");
604+
db.prepare("UPDATE cohorts SET start_date = ? WHERE id = ?")
605+
.run("2026-02-02", cohort.id);
606+
607+
const stmt = db.prepare("SELECT start_date FROM cohorts WHERE id = ?");
608+
const row = stmt.get(cohort.id) as { start_date: string };
609+
610+
expect(row.start_date).toBe("2026-02-02");
611+
});
612+
613+
it("parses start_date into a valid Date", () => {
614+
const since = new Date("2026-02-02");
615+
expect(since.getTime()).not.toBeNaN();
616+
expect(since.toISOString()).toBe("2026-02-02T00:00:00.000Z");
617+
});
618+
});
619+
620+
describe("message upsert on refresh", () => {
621+
it("inserts new messages into the database", () => {
622+
const channel = createTestChannel(db, "ch-1", "eod");
623+
const user = createTestUser(db, "user-1", "testuser");
624+
625+
db.prepare(`
626+
INSERT INTO messages (discord_message_id, channel_id, author_id, content, created_at)
627+
VALUES (?, ?, ?, ?, ?)
628+
`).run("msg-1", channel.channel_id, user.author_id, "Original EOD", "2026-02-06T18:00:00.000Z");
629+
630+
const stmt = db.prepare("SELECT content FROM messages WHERE discord_message_id = ?");
631+
const row = stmt.get("msg-1") as { content: string };
632+
expect(row.content).toBe("Original EOD");
633+
});
634+
635+
it("upserts edited messages to update content", () => {
636+
const channel = createTestChannel(db, "ch-1", "eod");
637+
const user = createTestUser(db, "user-1", "testuser");
638+
639+
// Insert original
640+
db.prepare(`
641+
INSERT INTO messages (discord_message_id, channel_id, author_id, content, created_at)
642+
VALUES (?, ?, ?, ?, ?)
643+
`).run("msg-1", channel.channel_id, user.author_id, "Original EOD", "2026-02-06T18:00:00.000Z");
644+
645+
// Upsert with updated content (same discord_message_id)
646+
db.prepare(`
647+
INSERT INTO messages (discord_message_id, channel_id, author_id, content, created_at)
648+
VALUES (?, ?, ?, ?, ?)
649+
ON CONFLICT(discord_message_id) DO UPDATE SET content = excluded.content
650+
`).run("msg-1", channel.channel_id, user.author_id, "Updated EOD with 9 PRs", "2026-02-06T18:00:00.000Z");
651+
652+
const stmt = db.prepare("SELECT content FROM messages WHERE discord_message_id = ?");
653+
const row = stmt.get("msg-1") as { content: string };
654+
expect(row.content).toBe("Updated EOD with 9 PRs");
655+
});
656+
657+
it("does not create duplicate rows on upsert", () => {
658+
const channel = createTestChannel(db, "ch-1", "eod");
659+
const user = createTestUser(db, "user-1", "testuser");
660+
661+
const insertStmt = db.prepare(`
662+
INSERT INTO messages (discord_message_id, channel_id, author_id, content, created_at)
663+
VALUES (?, ?, ?, ?, ?)
664+
ON CONFLICT(discord_message_id) DO UPDATE SET content = excluded.content
665+
`);
666+
667+
insertStmt.run("msg-1", channel.channel_id, user.author_id, "Version 1", "2026-02-06T18:00:00.000Z");
668+
insertStmt.run("msg-1", channel.channel_id, user.author_id, "Version 2", "2026-02-06T18:00:00.000Z");
669+
insertStmt.run("msg-1", channel.channel_id, user.author_id, "Version 3", "2026-02-06T18:00:00.000Z");
670+
671+
const count = db.prepare("SELECT COUNT(*) as count FROM messages WHERE discord_message_id = ?")
672+
.get("msg-1") as { count: number };
673+
expect(count.count).toBe(1);
674+
675+
const row = db.prepare("SELECT content FROM messages WHERE discord_message_id = ?")
676+
.get("msg-1") as { content: string };
677+
expect(row.content).toBe("Version 3");
678+
});
679+
});
680+
});

attendabot/frontend/src/api/client.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,27 @@ export async function getObservers(): Promise<Observer[]> {
848848
}
849849
}
850850

851+
/** Re-fetches Discord messages since cohort start date and upserts them into the database. */
852+
export async function refreshMessages(cohortId: number): Promise<{
853+
success: boolean;
854+
messagesProcessed?: number;
855+
error?: string;
856+
}> {
857+
try {
858+
const res = await fetchWithAuth(`${API_BASE}/cohorts/${cohortId}/refresh-messages`, {
859+
method: "POST",
860+
});
861+
const data = await res.json();
862+
if (!res.ok) {
863+
return { success: false, error: data.error || "Refresh failed" };
864+
}
865+
return { success: true, messagesProcessed: data.messagesProcessed };
866+
} catch (error) {
867+
console.error(error);
868+
return { success: false, error: "Network error" };
869+
}
870+
}
871+
851872
/** Syncs observers from the Discord @instructors role. */
852873
export async function syncObservers(): Promise<Observer[]> {
853874
try {

attendabot/frontend/src/components/StudentCohortPanel.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
deleteStudent,
1313
updateStudent,
1414
getObservers,
15+
refreshMessages,
1516
} from "../api/client";
1617
import { StudentTable } from "./StudentTable";
1718
import { StudentDetail } from "./StudentDetail";
@@ -29,6 +30,8 @@ export function StudentCohortPanel() {
2930
const [sidebarOpen, setSidebarOpen] = useState(false);
3031
const [modalOpen, setModalOpen] = useState(false);
3132
const [loading, setLoading] = useState(true);
33+
const [refreshing, setRefreshing] = useState(false);
34+
const [refreshStatus, setRefreshStatus] = useState<string | null>(null);
3235

3336
// Load cohorts and observers on mount
3437
useEffect(() => {
@@ -108,6 +111,21 @@ export function StudentCohortPanel() {
108111
}
109112
};
110113

114+
const handleRefreshMessages = async () => {
115+
if (selectedCohortId === null || refreshing) return;
116+
setRefreshing(true);
117+
setRefreshStatus(null);
118+
const result = await refreshMessages(selectedCohortId);
119+
if (result.success) {
120+
setRefreshStatus(`Refreshed ${result.messagesProcessed} messages`);
121+
loadStudents();
122+
} else {
123+
setRefreshStatus(`Error: ${result.error}`);
124+
}
125+
setRefreshing(false);
126+
setTimeout(() => setRefreshStatus(null), 5000);
127+
};
128+
111129
const handleStudentNameChange = async (newName: string) => {
112130
if (!selectedStudent) return;
113131
const updated = await updateStudent(selectedStudent.id, { name: newName });
@@ -139,6 +157,13 @@ export function StudentCohortPanel() {
139157
>
140158
Add Student
141159
</button>
160+
<button
161+
onClick={handleRefreshMessages}
162+
disabled={selectedCohortId === null || refreshing}
163+
>
164+
{refreshing ? "Refreshing..." : "Refresh Messages"}
165+
</button>
166+
{refreshStatus && <span className="refresh-status">{refreshStatus}</span>}
142167
</div>
143168

144169
{loading ? (

0 commit comments

Comments
 (0)