From c9046d5309ba75a94adc238aef52bab1a991a3c9 Mon Sep 17 00:00:00 2001 From: "augmentcode[bot]" <185243770+augmentcode[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:06:39 +0000 Subject: [PATCH 1/7] Add weekly low standup report automation - Create new automation task that runs every Monday at 9 AM - Identifies students with two consecutive standup scores under 2 from previous week - Posts formatted report to #stats Slack channel - Supports multiple active events with Slack integration - Uses Slack Block Kit for rich message formatting - Includes student Slack mentions when available - Follows existing codebase patterns and error handling --- .../tasks/weeklyLowStandupReport.ts | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 src/automation/tasks/weeklyLowStandupReport.ts diff --git a/src/automation/tasks/weeklyLowStandupReport.ts b/src/automation/tasks/weeklyLowStandupReport.ts new file mode 100644 index 0000000..c37e96e --- /dev/null +++ b/src/automation/tasks/weeklyLowStandupReport.ts @@ -0,0 +1,229 @@ +import { PrismaClient, StudentStatus } from "@prisma/client"; +import Container from "typedi"; +import { getSlackClientForEvent } from "../../slack"; +import { makeDebug } from "../../utils"; +import { DateTime } from "luxon"; +import { WebClient } from "@slack/web-api"; + +const DEBUG = makeDebug('automation:tasks:weeklyLowStandupReport'); + +// Run every Monday at 9 AM Pacific Time +export const JOBSPEC = '0 9 * * 1'; + +interface StudentWithLowStandups { + studentId: string; + givenName: string; + surname: string; + slackId: string | null; + eventName: string; + consecutiveLowScores: number; + lastTwoRatings: (number | null)[]; +} + +export default async function weeklyLowStandupReport(): Promise { + const prisma = Container.get(PrismaClient); + + DEBUG('Starting weekly low standup report...'); + + // Get all active events with Slack integration + const events = await prisma.event.findMany({ + where: { + isActive: true, + slackWorkspaceAccessToken: { not: null }, + slackWorkspaceId: { not: null }, + }, + select: { + id: true, + name: true, + slackWorkspaceAccessToken: true, + slackWorkspaceId: true, + }, + }); + + DEBUG(`Found ${events.length} active events with Slack integration.`); + + for (const event of events) { + try { + await sendReportForEvent(event); + } catch (ex) { + DEBUG(`Error sending report for event ${event.id}:`, ex); + } + } + + DEBUG('Weekly low standup report completed.'); +} + +async function sendReportForEvent(event: { + id: string; + name: string; + slackWorkspaceAccessToken: string; + slackWorkspaceId: string; +}): Promise { + const prisma = Container.get(PrismaClient); + const slack = getSlackClientForEvent(event); + + // Calculate date range for "previous week" (last 7 days from start of today) + const now = DateTime.now(); + const startOfToday = now.startOf('day'); + const oneWeekAgo = startOfToday.minus({ days: 7 }); + + DEBUG(`Checking standups from ${oneWeekAgo.toISO()} to ${startOfToday.toISO()} for event ${event.id}`); + + // Get all students in the active event + const students = await prisma.student.findMany({ + where: { + eventId: event.id, + status: StudentStatus.ACCEPTED, + }, + select: { + id: true, + givenName: true, + surname: true, + slackId: true, + standupResults: { + where: { + thread: { + dueAt: { + gte: oneWeekAgo.toJSDate(), + lt: startOfToday.toJSDate(), + }, + }, + }, + select: { + rating: true, + threadId: true, + thread: { + select: { + dueAt: true, + }, + }, + }, + orderBy: { + thread: { + dueAt: 'asc', + }, + }, + }, + }, + }); + + DEBUG(`Found ${students.length} accepted students in event ${event.id}`); + + // Filter students with two consecutive standup scores < 2 + const flaggedStudents: StudentWithLowStandups[] = []; + + for (const student of students) { + const ratings = student.standupResults.map(r => r.rating); + + if (ratings.length < 2) continue; + + // Check for two consecutive ratings both < 2 + for (let i = 0; i < ratings.length - 1; i++) { + const current = ratings[i]; + const next = ratings[i + 1]; + + if (current !== null && next !== null && current < 2 && next < 2) { + flaggedStudents.push({ + studentId: student.id, + givenName: student.givenName, + surname: student.surname, + slackId: student.slackId, + eventName: event.name, + consecutiveLowScores: 2, + lastTwoRatings: [current, next], + }); + break; // Only flag once per student + } + } + } + + DEBUG(`Found ${flaggedStudents.length} students with consecutive low standup scores`); + + if (flaggedStudents.length === 0) { + DEBUG('No students to report, skipping Slack message.'); + return; + } + + // Post to #stats channel + await postToStatsChannel(slack, event.name, flaggedStudents); +} + +async function postToStatsChannel( + slack: WebClient, + eventName: string, + students: StudentWithLowStandups[] +): Promise { + const STATS_CHANNEL_NAME = 'stats'; + + DEBUG(`Looking up channel: ${STATS_CHANNEL_NAME}`); + + // Find the stats channel + try { + const channelsList = await slack.conversations.list({ + exclude_archived: true, + types: 'public_channel,private_channel', + }); + + const statsChannel = channelsList.channels?.find( + (c: any) => c.name === STATS_CHANNEL_NAME + ); + + if (!statsChannel) { + DEBUG(`Channel #${STATS_CHANNEL_NAME} not found, skipping report.`); + return; + } + + DEBUG(`Found channel #${STATS_CHANNEL_NAME} with ID ${statsChannel.id}`); + + // Format the message + const studentList = students + .map(s => { + const slackMention = s.slackId ? `<@${s.slackId}>` : `${s.givenName} ${s.surname}`; + return `• ${slackMention} (${s.givenName} ${s.surname})`; + }) + .join('\n'); + + const message = { + channel: statsChannel.id, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: '⚠️ Weekly Low Standup Report', + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Event:* ${eventName}\n*Report Date:* ${DateTime.now().toLocaleString(DateTime.DATE_FULL)}`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `The following students had *two consecutive standup scores under 2* in the previous week:\n\n${studentList}`, + }, + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Total flagged students: ${students.length}`, + }, + ], + }, + ], + }; + + await slack.chat.postMessage(message); + DEBUG(`Successfully posted report to #${STATS_CHANNEL_NAME}`); + } catch (error) { + DEBUG(`Error posting to #${STATS_CHANNEL_NAME}:`, error); + throw error; + } +} From a82fa504836c7b2798f71ea48a9b1b913c1d4cb3 Mon Sep 17 00:00:00 2001 From: "augmentcode[bot]" <185243770+augmentcode[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:42:47 +0000 Subject: [PATCH 2/7] Add unit tests and fix TypeScript errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor filtering logic into testable pure functions - Export findConsecutiveLowScores and formatStudentList for testing - Add comprehensive manual test suite (12 test cases) - Fix TypeScript type errors with PickNonNullable - All tests passing ✅ --- .../weeklyLowStandupReport.manual-test.ts | 209 ++++++++++++++++++ .../tasks/weeklyLowStandupReport.ts | 114 ++++++---- 2 files changed, 278 insertions(+), 45 deletions(-) create mode 100644 src/automation/tasks/weeklyLowStandupReport.manual-test.ts diff --git a/src/automation/tasks/weeklyLowStandupReport.manual-test.ts b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts new file mode 100644 index 0000000..8b4cca0 --- /dev/null +++ b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts @@ -0,0 +1,209 @@ +/** + * Manual test script for weeklyLowStandupReport + * Run with: npx ts-node --transpile-only src/automation/tasks/weeklyLowStandupReport.manual-test.ts + * + * Note: This only tests the pure functions (findConsecutiveLowScores and formatStudentList) + * and does not require database or Slack credentials. + */ + +interface StudentWithLowStandups { + studentId: string; + givenName: string; + surname: string; + slackId: string | null; + eventName: string; + consecutiveLowScores: number; + lastTwoRatings: (number | null)[]; +} + +// Copy the functions here to avoid loading dependencies +function findConsecutiveLowScores( + student: { + id: string; + givenName: string; + surname: string; + slackId: string | null; + standupResults: { rating: number | null }[]; + }, + eventName: string +): StudentWithLowStandups | null { + const ratings = student.standupResults.map(r => r.rating); + + if (ratings.length < 2) return null; + + // Check for two consecutive ratings both < 2 + for (let i = 0; i < ratings.length - 1; i++) { + const current = ratings[i]; + const next = ratings[i + 1]; + + if (current !== null && next !== null && current < 2 && next < 2) { + return { + studentId: student.id, + givenName: student.givenName, + surname: student.surname, + slackId: student.slackId, + eventName, + consecutiveLowScores: 2, + lastTwoRatings: [current, next], + }; + } + } + + return null; +} + +function formatStudentList(students: StudentWithLowStandups[]): string { + return students + .map(s => { + const slackMention = s.slackId ? `<@${s.slackId}>` : `${s.givenName} ${s.surname}`; + return `• ${slackMention} (${s.givenName} ${s.surname})`; + }) + .join('\n'); +} + +// Simple assertion helper +function assert(condition: boolean, message: string) { + if (!condition) { + console.error(`❌ FAILED: ${message}`); + process.exit(1); + } + console.log(`✅ PASSED: ${message}`); +} + +function assertEqual(actual: T, expected: T, message: string) { + const actualStr = JSON.stringify(actual); + const expectedStr = JSON.stringify(expected); + if (actualStr !== expectedStr) { + console.error(`❌ FAILED: ${message}`); + console.error(` Expected: ${expectedStr}`); + console.error(` Actual: ${actualStr}`); + process.exit(1); + } + console.log(`✅ PASSED: ${message}`); +} + +console.log('\n🧪 Testing findConsecutiveLowScores...\n'); + +// Test 1: Student with fewer than 2 results +const result1 = findConsecutiveLowScores({ + id: 'student-1', + givenName: 'Alice', + surname: 'Smith', + slackId: 'U123456', + standupResults: [{ rating: 1 }], +}, 'Test Event'); +assertEqual(result1, null, 'Should return null for student with < 2 results'); + +// Test 2: Student with two consecutive low scores +const result2 = findConsecutiveLowScores({ + id: 'student-2', + givenName: 'Bob', + surname: 'Jones', + slackId: 'U789', + standupResults: [{ rating: 1 }, { rating: 1 }], +}, 'Test Event'); +assert(result2 !== null, 'Should flag student with two consecutive scores of 1'); +assert(result2?.givenName === 'Bob', 'Should preserve student name'); +assert(result2?.lastTwoRatings[0] === 1 && result2?.lastTwoRatings[1] === 1, 'Should capture both ratings'); + +// Test 3: Student with one low, one acceptable +const result3 = findConsecutiveLowScores({ + id: 'student-3', + givenName: 'Charlie', + surname: 'Brown', + slackId: null, + standupResults: [{ rating: 1 }, { rating: 2 }], +}, 'Test Event'); +assertEqual(result3, null, 'Should NOT flag student with 1 then 2'); + +// Test 4: Student with scores 2 or higher +const result4 = findConsecutiveLowScores({ + id: 'student-4', + givenName: 'David', + surname: 'Wilson', + slackId: 'U456', + standupResults: [{ rating: 2 }, { rating: 3 }], +}, 'Test Event'); +assertEqual(result4, null, 'Should NOT flag student with all scores >= 2'); + +// Test 5: Consecutive low scores in the middle +const result5 = findConsecutiveLowScores({ + id: 'student-5', + givenName: 'Emily', + surname: 'Davis', + slackId: 'U999', + standupResults: [{ rating: 3 }, { rating: 1 }, { rating: 1 }, { rating: 3 }], +}, 'Test Event'); +assert(result5 !== null, 'Should flag student with consecutive low scores in the middle'); + +// Test 6: Handle null ratings +const result6 = findConsecutiveLowScores({ + id: 'student-6', + givenName: 'Frank', + surname: 'Miller', + slackId: null, + standupResults: [{ rating: null }, { rating: 1 }, { rating: 1 }], +}, 'Test Event'); +assert(result6 !== null, 'Should flag when consecutive low scores exist (ignoring nulls)'); + +// Test 7: Only null ratings +const result7 = findConsecutiveLowScores({ + id: 'student-7', + givenName: 'Grace', + surname: 'Lee', + slackId: null, + standupResults: [{ rating: null }, { rating: null }], +}, 'Test Event'); +assertEqual(result7, null, 'Should NOT flag when only null ratings exist'); + +console.log('\n🧪 Testing formatStudentList...\n'); + +// Test 8: Format single student with Slack ID +const formatted1 = formatStudentList([{ + studentId: 'student-1', + givenName: 'Alice', + surname: 'Smith', + slackId: 'U123456', + eventName: 'Test Event', + consecutiveLowScores: 2, + lastTwoRatings: [1, 1], +}]); +assertEqual(formatted1, '• <@U123456> (Alice Smith)', 'Should format with Slack mention'); + +// Test 9: Format single student without Slack ID +const formatted2 = formatStudentList([{ + studentId: 'student-2', + givenName: 'Bob', + surname: 'Jones', + slackId: null, + eventName: 'Test Event', + consecutiveLowScores: 2, + lastTwoRatings: [0, 1], +}]); +assertEqual(formatted2, '• Bob Jones (Bob Jones)', 'Should format without Slack mention'); + +// Test 10: Format multiple students +const formatted3 = formatStudentList([ + { + studentId: 'student-1', + givenName: 'Alice', + surname: 'Smith', + slackId: 'U123', + eventName: 'Test Event', + consecutiveLowScores: 2, + lastTwoRatings: [1, 1], + }, + { + studentId: 'student-2', + givenName: 'Bob', + surname: 'Jones', + slackId: null, + eventName: 'Test Event', + consecutiveLowScores: 2, + lastTwoRatings: [0, 1], + }, +]); +const expected = '• <@U123> (Alice Smith)\n• Bob Jones (Bob Jones)'; +assertEqual(formatted3, expected, 'Should format multiple students with newlines'); + +console.log('\n✨ All tests passed!\n'); diff --git a/src/automation/tasks/weeklyLowStandupReport.ts b/src/automation/tasks/weeklyLowStandupReport.ts index c37e96e..72af1c0 100644 --- a/src/automation/tasks/weeklyLowStandupReport.ts +++ b/src/automation/tasks/weeklyLowStandupReport.ts @@ -1,9 +1,10 @@ import { PrismaClient, StudentStatus } from "@prisma/client"; import Container from "typedi"; import { getSlackClientForEvent } from "../../slack"; -import { makeDebug } from "../../utils"; +import { makeDebug, PickNonNullable } from "../../utils"; import { DateTime } from "luxon"; import { WebClient } from "@slack/web-api"; +import { Event } from "@prisma/client"; const DEBUG = makeDebug('automation:tasks:weeklyLowStandupReport'); @@ -38,7 +39,7 @@ export default async function weeklyLowStandupReport(): Promise { slackWorkspaceAccessToken: true, slackWorkspaceId: true, }, - }); + }) as (PickNonNullable & Pick)[]; DEBUG(`Found ${events.length} active events with Slack integration.`); @@ -53,12 +54,9 @@ export default async function weeklyLowStandupReport(): Promise { DEBUG('Weekly low standup report completed.'); } -async function sendReportForEvent(event: { - id: string; - name: string; - slackWorkspaceAccessToken: string; - slackWorkspaceId: string; -}): Promise { +async function sendReportForEvent( + event: PickNonNullable & Pick +): Promise { const prisma = Container.get(PrismaClient); const slack = getSlackClientForEvent(event); @@ -110,32 +108,9 @@ async function sendReportForEvent(event: { DEBUG(`Found ${students.length} accepted students in event ${event.id}`); // Filter students with two consecutive standup scores < 2 - const flaggedStudents: StudentWithLowStandups[] = []; - - for (const student of students) { - const ratings = student.standupResults.map(r => r.rating); - - if (ratings.length < 2) continue; - - // Check for two consecutive ratings both < 2 - for (let i = 0; i < ratings.length - 1; i++) { - const current = ratings[i]; - const next = ratings[i + 1]; - - if (current !== null && next !== null && current < 2 && next < 2) { - flaggedStudents.push({ - studentId: student.id, - givenName: student.givenName, - surname: student.surname, - slackId: student.slackId, - eventName: event.name, - consecutiveLowScores: 2, - lastTwoRatings: [current, next], - }); - break; // Only flag once per student - } - } - } + const flaggedStudents = students + .map(student => findConsecutiveLowScores(student, event.name)) + .filter((student): student is StudentWithLowStandups => student !== null); DEBUG(`Found ${flaggedStudents.length} students with consecutive low standup scores`); @@ -148,6 +123,61 @@ async function sendReportForEvent(event: { await postToStatsChannel(slack, event.name, flaggedStudents); } +/** + * Checks if a student has two consecutive standup scores under 2. + * Returns the student with flagging info, or null if they don't meet criteria. + * + * Exported for testing. + */ +export function findConsecutiveLowScores( + student: { + id: string; + givenName: string; + surname: string; + slackId: string | null; + standupResults: { rating: number | null }[]; + }, + eventName: string +): StudentWithLowStandups | null { + const ratings = student.standupResults.map(r => r.rating); + + if (ratings.length < 2) return null; + + // Check for two consecutive ratings both < 2 + for (let i = 0; i < ratings.length - 1; i++) { + const current = ratings[i]; + const next = ratings[i + 1]; + + if (current !== null && next !== null && current < 2 && next < 2) { + return { + studentId: student.id, + givenName: student.givenName, + surname: student.surname, + slackId: student.slackId, + eventName, + consecutiveLowScores: 2, + lastTwoRatings: [current, next], + }; + } + } + + return null; +} + +/** + * Formats a list of students into a Slack message string. + * + * Exported for testing. + */ +export function formatStudentList(students: StudentWithLowStandups[]): string { + return students + .map(s => { + const slackMention = s.slackId ? `<@${s.slackId}>` : `${s.givenName} ${s.surname}`; + return `• ${slackMention} (${s.givenName} ${s.surname})`; + }) + .join('\n'); +} + async function postToStatsChannel( slack: WebClient, eventName: string, @@ -176,15 +206,10 @@ async function postToStatsChannel( DEBUG(`Found channel #${STATS_CHANNEL_NAME} with ID ${statsChannel.id}`); // Format the message - const studentList = students - .map(s => { - const slackMention = s.slackId ? `<@${s.slackId}>` : `${s.givenName} ${s.surname}`; - return `• ${slackMention} (${s.givenName} ${s.surname})`; - }) - .join('\n'); - - const message = { - channel: statsChannel.id, + const studentList = formatStudentList(students); + + await slack.chat.postMessage({ + channel: statsChannel.id!, blocks: [ { type: 'header', @@ -218,9 +243,8 @@ async function postToStatsChannel( ], }, ], - }; + }); - await slack.chat.postMessage(message); DEBUG(`Successfully posted report to #${STATS_CHANNEL_NAME}`); } catch (error) { DEBUG(`Error posting to #${STATS_CHANNEL_NAME}:`, error); From ecf208ffdfb8cb95b2ec21401dc2a4e2b901251e Mon Sep 17 00:00:00 2001 From: "augmentcode[bot]" <185243770+augmentcode[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:52:50 +0000 Subject: [PATCH 3/7] Refactor tests to import from source (DRY) - Remove duplicated function code from test file - Import findConsecutiveLowScores and formatStudentList from actual implementation - Add .env.test.example with minimal test environment variables - Update test documentation with setup instructions - Tests now stay in sync with implementation automatically --- .env.test.example | 29 ++++++++ .../weeklyLowStandupReport.manual-test.ts | 68 +++---------------- 2 files changed, 40 insertions(+), 57 deletions(-) create mode 100644 .env.test.example diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..8c49940 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,29 @@ +# Minimal environment variables for running tests +# Copy this to .env for testing purposes + +DATABASE_URL=postgresql://test:test@localhost:5432/test +ELASTIC_URL=http://localhost:9200 +ELASTIC_INDEX=test +AUTH_SECRET=test-secret +AUTH_AUDIENCE=test-audience +EMAIL_HOST=localhost +EMAIL_PORT=587 +EMAIL_USER=test +EMAIL_PASS=test +EMAIL_INBOUND_DOMAIN=test.local +GEOCODIO_API_KEY=test-key +OPENAI_API_KEY=test-key +OPENAI_ORGANIZATION=test-org +WEBHOOK_KEY=test-key +BADGR_USERNAME=test +BADGR_PASSWORD=test +BADGR_ISSUER=test +SHOPIFY_API_TOKEN=test +SHOPIFY_API_KEY=test +SHOPIFY_API_SECRET_KEY=test +SHOPIFY_STORE_DOMAIN=test.myshopify.com +LINEAR_API_KEY=test +LINEAR_TEAM_ID=test +LINEAR_PROBLEM_LABEL_ID=test +METRICS_KEY=test +PLACID_API_TOKEN=test diff --git a/src/automation/tasks/weeklyLowStandupReport.manual-test.ts b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts index 8b4cca0..dfcf4de 100644 --- a/src/automation/tasks/weeklyLowStandupReport.manual-test.ts +++ b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts @@ -1,65 +1,19 @@ /** * Manual test script for weeklyLowStandupReport - * Run with: npx ts-node --transpile-only src/automation/tasks/weeklyLowStandupReport.manual-test.ts * - * Note: This only tests the pure functions (findConsecutiveLowScores and formatStudentList) - * and does not require database or Slack credentials. + * Prerequisites: + * - Copy .env.test.example to .env (if you don't have a .env file): + * cp .env.test.example .env + * + * Run with: + * npx ts-node src/automation/tasks/weeklyLowStandupReport.manual-test.ts + * + * This tests the pure functions (findConsecutiveLowScores and formatStudentList) + * imported from the actual implementation file to ensure tests stay in sync with code. */ -interface StudentWithLowStandups { - studentId: string; - givenName: string; - surname: string; - slackId: string | null; - eventName: string; - consecutiveLowScores: number; - lastTwoRatings: (number | null)[]; -} - -// Copy the functions here to avoid loading dependencies -function findConsecutiveLowScores( - student: { - id: string; - givenName: string; - surname: string; - slackId: string | null; - standupResults: { rating: number | null }[]; - }, - eventName: string -): StudentWithLowStandups | null { - const ratings = student.standupResults.map(r => r.rating); - - if (ratings.length < 2) return null; - - // Check for two consecutive ratings both < 2 - for (let i = 0; i < ratings.length - 1; i++) { - const current = ratings[i]; - const next = ratings[i + 1]; - - if (current !== null && next !== null && current < 2 && next < 2) { - return { - studentId: student.id, - givenName: student.givenName, - surname: student.surname, - slackId: student.slackId, - eventName, - consecutiveLowScores: 2, - lastTwoRatings: [current, next], - }; - } - } - - return null; -} - -function formatStudentList(students: StudentWithLowStandups[]): string { - return students - .map(s => { - const slackMention = s.slackId ? `<@${s.slackId}>` : `${s.givenName} ${s.surname}`; - return `• ${slackMention} (${s.givenName} ${s.surname})`; - }) - .join('\n'); -} +import 'reflect-metadata'; +import { findConsecutiveLowScores, formatStudentList } from './weeklyLowStandupReport'; // Simple assertion helper function assert(condition: boolean, message: string) { From a8089933c05016952146baaa5ac0b710e2bc9026 Mon Sep 17 00:00:00 2001 From: "augmentcode[bot]" <185243770+augmentcode[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:55:53 +0000 Subject: [PATCH 4/7] Add Slack integration test script and documentation - Create testWeeklyStandupReport.ts manual testing script - Support dry-run mode to preview messages without posting - Support custom channel selection for testing - Use fake student data to avoid pinging real people - Add comprehensive testing documentation - Include troubleshooting guide Usage: npx ts-node scripts/testWeeklyStandupReport.ts --dry-run npx ts-node scripts/testWeeklyStandupReport.ts --channel=test-notifications --- docs/testing-weekly-standup-report.md | 152 +++++++++++++++++++ scripts/testWeeklyStandupReport.ts | 206 ++++++++++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 docs/testing-weekly-standup-report.md create mode 100644 scripts/testWeeklyStandupReport.ts diff --git a/docs/testing-weekly-standup-report.md b/docs/testing-weekly-standup-report.md new file mode 100644 index 0000000..2f841ef --- /dev/null +++ b/docs/testing-weekly-standup-report.md @@ -0,0 +1,152 @@ +# Testing the Weekly Low Standup Report + +This guide explains how to test the weekly Slack notification for students with consecutive low standup scores. + +## Overview + +The automation (`weeklyLowStandupReport.ts`) runs every Monday at 9 AM and posts to the `#stats` Slack channel. Before deploying, you should test it to ensure: +- The message formatting looks correct +- The Slack integration works +- The channel lookup succeeds +- No real students are pinged accidentally + +## Prerequisites + +1. **Environment variables** set up (copy `.env.test.example` to `.env` or use your real `.env`) +2. **Database access** to an active event with Slack integration +3. **Slack workspace access** - you need to be a member of the workspace + +## Test Methods + +### Method 1: Dry Run (Preview Only) ⭐ **Recommended First Step** + +Preview the message without posting to Slack: + +```bash +npx ts-node scripts/testWeeklyStandupReport.ts --dry-run +``` + +This will: +- ✅ Show the exact JSON that would be sent to Slack +- ✅ Validate your Slack credentials +- ✅ Find the channel +- ❌ NOT post anything to Slack + +**Output:** +```json +{ + "channel": "C01234567", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "⚠️ Weekly Low Standup Report [TEST]", + ... + } + }, + ... + ] +} +``` + +### Method 2: Post to Test Channel + +Create a private test channel (e.g., `#test-notifications`) and post there: + +```bash +npx ts-node scripts/testWeeklyStandupReport.ts --channel=test-notifications +``` + +This will: +- ✅ Post an actual message to Slack +- ✅ Use fake student data (no real students pinged) +- ✅ Let you see the formatted message in Slack +- ✅ Test the full integration + +### Method 3: Post to #stats with Fake Data + +When you're confident the formatting is correct: + +```bash +npx ts-node scripts/testWeeklyStandupReport.ts --channel=stats +``` + +**⚠️ Note:** This posts to the real `#stats` channel but uses fake test data: +- Student names: "Alice TestStudent", "Bob DemoUser", "Charlie SampleStudent" +- No Slack IDs (so no @mentions) +- Clearly marked as **[TEST]** in the header + +### Method 4: Use Real Data (Not Yet Implemented) + +```bash +npx ts-node scripts/testWeeklyStandupReport.ts --channel=test-notifications --use-real-data +``` + +Currently falls back to fake data. To implement real data fetching, see TODO in script. + +## What the Test Message Looks Like + +The message will appear in Slack as: + +--- + +**⚠️ Weekly Low Standup Report [TEST]** + +**Event:** CodeDay Labs Test +**Report Date:** June 16, 2026 +**Mode:** Fake Test Data + +The following students had **two consecutive standup scores under 2** in the previous week: + +• Alice TestStudent (Alice TestStudent) +• Bob DemoUser (Bob DemoUser) +• Charlie SampleStudent (Charlie SampleStudent) + +*Total flagged students: 3 | This is a TEST message* + +--- + +## Troubleshooting + +### "No active event with Slack integration found" + +Your database doesn't have an event with: +- `isActive = true` +- `slackWorkspaceAccessToken` set +- `slackWorkspaceId` set + +**Fix:** Check your database or create a test event with Slack credentials. + +### "Channel #test-notifications not found" + +The channel doesn't exist or the bot doesn't have access. + +**Fix:** +1. Create the channel in Slack +2. Invite the bot to the channel: `/invite @BotName` +3. Or use `--channel=stats` if you know #stats exists + +### "Available channels:" shows no channels + +The Slack token might not have the right permissions. + +**Fix:** Ensure the Slack app has `channels:read` and `groups:read` scopes. + +## Next Steps + +After successful testing: +1. ✅ Verify message formatting looks good +2. ✅ Confirm the [TEST] marker is clear +3. ✅ Remove test messages from channels (or ignore them) +4. ✅ The automation will run automatically on Mondays at 9 AM + +## Running the Real Automation Manually + +To trigger the actual automation (not the test script): + +```bash +npx ts-node -e "require('./dist/automation/tasks/weeklyLowStandupReport').default()" +``` + +This uses real data and posts to #stats without any test markers. diff --git a/scripts/testWeeklyStandupReport.ts b/scripts/testWeeklyStandupReport.ts new file mode 100644 index 0000000..247f78a --- /dev/null +++ b/scripts/testWeeklyStandupReport.ts @@ -0,0 +1,206 @@ +/** + * Manual test script for the weekly standup report Slack integration + * + * This script allows you to test the Slack message formatting and posting + * without waiting for Monday or affecting real students. + * + * Usage: + * # Dry run - preview message without posting + * npx ts-node scripts/testWeeklyStandupReport.ts --dry-run + * + * # Post to a test channel + * npx ts-node scripts/testWeeklyStandupReport.ts --channel=test-notifications + * + * # Post to #stats with fake data + * npx ts-node scripts/testWeeklyStandupReport.ts --channel=stats + * + * # Use real data from the database + * npx ts-node scripts/testWeeklyStandupReport.ts --channel=test-notifications --use-real-data + */ + +import 'reflect-metadata'; +import { PrismaClient } from '@prisma/client'; +import Container from 'typedi'; +import { WebClient } from '@slack/web-api'; +import { DateTime } from 'luxon'; +import { formatStudentList } from '../src/automation/tasks/weeklyLowStandupReport'; + +// Parse command line arguments +const args = process.argv.slice(2); +const isDryRun = args.includes('--dry-run'); +const useRealData = args.includes('--use-real-data'); +const channelArg = args.find(arg => arg.startsWith('--channel=')); +const channelName = channelArg ? channelArg.split('=')[1] : 'stats'; + +interface TestStudent { + studentId: string; + givenName: string; + surname: string; + slackId: string | null; + eventName: string; + consecutiveLowScores: number; + lastTwoRatings: (number | null)[]; +} + +// Fake test data +const FAKE_STUDENTS: TestStudent[] = [ + { + studentId: 'test-1', + givenName: 'Alice', + surname: 'TestStudent', + slackId: null, // No Slack ID to avoid pinging + eventName: 'CodeDay Labs Test', + consecutiveLowScores: 2, + lastTwoRatings: [1, 1], + }, + { + studentId: 'test-2', + givenName: 'Bob', + surname: 'DemoUser', + slackId: null, + eventName: 'CodeDay Labs Test', + consecutiveLowScores: 2, + lastTwoRatings: [0, 1], + }, + { + studentId: 'test-3', + givenName: 'Charlie', + surname: 'SampleStudent', + slackId: null, + eventName: 'CodeDay Labs Test', + consecutiveLowScores: 2, + lastTwoRatings: [1, 0], + }, +]; + +async function postTestMessage( + slack: WebClient, + channelName: string, + students: TestStudent[], + eventName: string +): Promise { + // Find the channel + const channelsList = await slack.conversations.list({ + exclude_archived: true, + types: 'public_channel,private_channel', + }); + + const channel = channelsList.channels?.find( + (c: any) => c.name === channelName + ); + + if (!channel) { + console.error(`❌ Channel #${channelName} not found.`); + console.log('\nAvailable channels:'); + channelsList.channels?.slice(0, 10).forEach((c: any) => { + console.log(` - #${c.name} (${c.id})`); + }); + return; + } + + console.log(`✅ Found channel #${channelName} (${channel.id})`); + + const studentList = formatStudentList(students); + + const message = { + channel: channel.id!, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: '⚠️ Weekly Low Standup Report [TEST]', + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Event:* ${eventName}\n*Report Date:* ${DateTime.now().toLocaleString(DateTime.DATE_FULL)}\n*Mode:* ${useRealData ? 'Real Data' : 'Fake Test Data'}`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `The following students had *two consecutive standup scores under 2* in the previous week:\n\n${studentList}`, + }, + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Total flagged students: ${students.length} | This is a TEST message`, + }, + ], + }, + ], + }; + + if (isDryRun) { + console.log('\n📋 DRY RUN - Message preview:'); + console.log(JSON.stringify(message, null, 2)); + console.log('\n✅ Dry run complete - no message posted'); + } else { + await slack.chat.postMessage(message); + console.log(`\n✅ Test message posted to #${channelName}`); + } +} + +async function main() { + console.log('🧪 Weekly Standup Report - Test Script\n'); + console.log(`Mode: ${isDryRun ? 'DRY RUN' : 'LIVE'}`); + console.log(`Channel: #${channelName}`); + console.log(`Data: ${useRealData ? 'Real from database' : 'Fake test data'}\n`); + + const prisma = Container.get(PrismaClient); + + let students: TestStudent[]; + let eventName: string; + + if (useRealData) { + // TODO: Implement real data fetching + console.log('⚠️ Real data mode not yet implemented, using fake data'); + students = FAKE_STUDENTS; + eventName = 'CodeDay Labs Test'; + } else { + students = FAKE_STUDENTS; + eventName = 'CodeDay Labs Test'; + } + + // Get the first active event with Slack integration + const event = await prisma.event.findFirst({ + where: { + isActive: true, + slackWorkspaceAccessToken: { not: null }, + slackWorkspaceId: { not: null }, + }, + }); + + if (!event) { + console.error('❌ No active event with Slack integration found'); + process.exit(1); + } + + console.log(`Using event: ${event.name} (${event.id})\n`); + + // Create Slack client + const slack = new WebClient(event.slackWorkspaceAccessToken!, { + teamId: event.slackWorkspaceId!, + }); + + await postTestMessage(slack, channelName, students, eventName); +} + +main() + .then(() => { + console.log('\n✨ Test complete'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Error:', error); + process.exit(1); + }); + From 77816f07a918fa11a253c16d28036a6865b5e075 Mon Sep 17 00:00:00 2001 From: Hannah Cotterell Date: Tue, 14 Jul 2026 12:31:22 -0700 Subject: [PATCH 5/7] Adds mentor look up and testing --- scripts/testWeeklyStandupReport.ts | 125 +++++++---- .../weeklyLowStandupReport.manual-test.ts | 210 +++++++++++++++++- .../tasks/weeklyLowStandupReport.ts | 44 +++- 3 files changed, 328 insertions(+), 51 deletions(-) diff --git a/scripts/testWeeklyStandupReport.ts b/scripts/testWeeklyStandupReport.ts index 247f78a..35eb6b1 100644 --- a/scripts/testWeeklyStandupReport.ts +++ b/scripts/testWeeklyStandupReport.ts @@ -24,6 +24,7 @@ import Container from 'typedi'; import { WebClient } from '@slack/web-api'; import { DateTime } from 'luxon'; import { formatStudentList } from '../src/automation/tasks/weeklyLowStandupReport'; +import { registerDi } from '../src/di'; // Parse command line arguments const args = process.argv.slice(2); @@ -37,6 +38,11 @@ interface TestStudent { givenName: string; surname: string; slackId: string | null; + assignedMentors: { + givenName: string; + surname: string; + slackId: string | null; + }[]; eventName: string; consecutiveLowScores: number; lastTwoRatings: (number | null)[]; @@ -49,6 +55,11 @@ const FAKE_STUDENTS: TestStudent[] = [ givenName: 'Alice', surname: 'TestStudent', slackId: null, // No Slack ID to avoid pinging + assignedMentors: [{ + givenName: 'Morgan', + surname: 'Lee', + slackId: null, + }], eventName: 'CodeDay Labs Test', consecutiveLowScores: 2, lastTwoRatings: [1, 1], @@ -58,6 +69,11 @@ const FAKE_STUDENTS: TestStudent[] = [ givenName: 'Bob', surname: 'DemoUser', slackId: null, + assignedMentors: [{ + givenName: 'Riley', + surname: 'Park', + slackId: null, + }], eventName: 'CodeDay Labs Test', consecutiveLowScores: 2, lastTwoRatings: [0, 1], @@ -67,6 +83,7 @@ const FAKE_STUDENTS: TestStudent[] = [ givenName: 'Charlie', surname: 'SampleStudent', slackId: null, + assignedMentors: [], eventName: 'CodeDay Labs Test', consecutiveLowScores: 2, lastTwoRatings: [1, 0], @@ -74,36 +91,15 @@ const FAKE_STUDENTS: TestStudent[] = [ ]; async function postTestMessage( - slack: WebClient, + slack: WebClient | null, channelName: string, students: TestStudent[], eventName: string ): Promise { - // Find the channel - const channelsList = await slack.conversations.list({ - exclude_archived: true, - types: 'public_channel,private_channel', - }); - - const channel = channelsList.channels?.find( - (c: any) => c.name === channelName - ); - - if (!channel) { - console.error(`❌ Channel #${channelName} not found.`); - console.log('\nAvailable channels:'); - channelsList.channels?.slice(0, 10).forEach((c: any) => { - console.log(` - #${c.name} (${c.id})`); - }); - return; - } - - console.log(`✅ Found channel #${channelName} (${channel.id})`); - const studentList = formatStudentList(students); const message = { - channel: channel.id!, + channel: channelName, blocks: [ { type: 'header', @@ -143,10 +139,38 @@ async function postTestMessage( console.log('\n📋 DRY RUN - Message preview:'); console.log(JSON.stringify(message, null, 2)); console.log('\n✅ Dry run complete - no message posted'); - } else { - await slack.chat.postMessage(message); - console.log(`\n✅ Test message posted to #${channelName}`); + return; + } + + if (!slack) { + throw new Error('Slack client is required for live posting.'); + } + + const channelsList = await slack.conversations.list({ + exclude_archived: true, + types: 'public_channel,private_channel', + }); + + const channel = channelsList.channels?.find( + (c: any) => c.name === channelName + ); + + if (!channel) { + console.error(`❌ Channel #${channelName} not found.`); + console.log('\nAvailable channels:'); + channelsList.channels?.slice(0, 10).forEach((c: any) => { + console.log(` - #${c.name} (${c.id})`); + }); + return; } + + console.log(`✅ Found channel #${channelName} (${channel.id})`); + + await slack.chat.postMessage({ + ...message, + channel: channel.id!, + }); + console.log(`\n✅ Test message posted to #${channelName}`); } async function main() { @@ -155,8 +179,6 @@ async function main() { console.log(`Channel: #${channelName}`); console.log(`Data: ${useRealData ? 'Real from database' : 'Fake test data'}\n`); - const prisma = Container.get(PrismaClient); - let students: TestStudent[]; let eventName: string; @@ -170,28 +192,39 @@ async function main() { eventName = 'CodeDay Labs Test'; } - // Get the first active event with Slack integration - const event = await prisma.event.findFirst({ - where: { - isActive: true, - slackWorkspaceAccessToken: { not: null }, - slackWorkspaceId: { not: null }, - }, - }); - - if (!event) { - console.error('❌ No active event with Slack integration found'); - process.exit(1); + if (isDryRun) { + await postTestMessage(null, channelName, students, eventName); + return; } - console.log(`Using event: ${event.name} (${event.id})\n`); + registerDi(); + const prisma = Container.get(PrismaClient); + + try { + const event = await prisma.event.findFirst({ + where: { + isActive: true, + slackWorkspaceAccessToken: { not: null }, + slackWorkspaceId: { not: null }, + }, + }); - // Create Slack client - const slack = new WebClient(event.slackWorkspaceAccessToken!, { - teamId: event.slackWorkspaceId!, - }); + if (!event) { + console.error('❌ No active event with Slack integration found'); + process.exit(1); + } - await postTestMessage(slack, channelName, students, eventName); + console.log(`Using event: ${event.name} (${event.id})\n`); + + // Create Slack client + const slack = new WebClient(event.slackWorkspaceAccessToken!, { + teamId: event.slackWorkspaceId!, + }); + + await postTestMessage(slack, channelName, students, eventName); + } finally { + await prisma.$disconnect(); + } } main() diff --git a/src/automation/tasks/weeklyLowStandupReport.manual-test.ts b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts index dfcf4de..d5792fa 100644 --- a/src/automation/tasks/weeklyLowStandupReport.manual-test.ts +++ b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts @@ -8,12 +8,20 @@ * Run with: * npx ts-node src/automation/tasks/weeklyLowStandupReport.manual-test.ts * + * To test Slack channel lookup, set SLACK_BOT_TOKEN environment variable: + * SLACK_BOT_TOKEN=xoxb-your-token npx ts-node src/automation/tasks/weeklyLowStandupReport.manual-test.ts + * * This tests the pure functions (findConsecutiveLowScores and formatStudentList) * imported from the actual implementation file to ensure tests stay in sync with code. + * It also optionally tests Slack channel lookup if a token is provided. */ import 'reflect-metadata'; import { findConsecutiveLowScores, formatStudentList } from './weeklyLowStandupReport'; +import { WebClient } from '@slack/web-api'; +import { PrismaClient } from '@prisma/client'; +import { registerDi } from '../../di'; +import Container from 'typedi'; // Simple assertion helper function assert(condition: boolean, message: string) { @@ -44,6 +52,7 @@ const result1 = findConsecutiveLowScores({ givenName: 'Alice', surname: 'Smith', slackId: 'U123456', + projects: [], standupResults: [{ rating: 1 }], }, 'Test Event'); assertEqual(result1, null, 'Should return null for student with < 2 results'); @@ -54,11 +63,24 @@ const result2 = findConsecutiveLowScores({ givenName: 'Bob', surname: 'Jones', slackId: 'U789', + projects: [ + { + mentors: [ + { + id: 'mentor-1', + givenName: 'Morgan', + surname: 'Lee', + slackId: 'UMENTOR1', + }, + ], + }, + ], standupResults: [{ rating: 1 }, { rating: 1 }], }, 'Test Event'); assert(result2 !== null, 'Should flag student with two consecutive scores of 1'); assert(result2?.givenName === 'Bob', 'Should preserve student name'); assert(result2?.lastTwoRatings[0] === 1 && result2?.lastTwoRatings[1] === 1, 'Should capture both ratings'); +assert(result2?.assignedMentors.length === 1, 'Should include assigned mentor data'); // Test 3: Student with one low, one acceptable const result3 = findConsecutiveLowScores({ @@ -66,6 +88,7 @@ const result3 = findConsecutiveLowScores({ givenName: 'Charlie', surname: 'Brown', slackId: null, + projects: [], standupResults: [{ rating: 1 }, { rating: 2 }], }, 'Test Event'); assertEqual(result3, null, 'Should NOT flag student with 1 then 2'); @@ -76,6 +99,7 @@ const result4 = findConsecutiveLowScores({ givenName: 'David', surname: 'Wilson', slackId: 'U456', + projects: [], standupResults: [{ rating: 2 }, { rating: 3 }], }, 'Test Event'); assertEqual(result4, null, 'Should NOT flag student with all scores >= 2'); @@ -86,6 +110,7 @@ const result5 = findConsecutiveLowScores({ givenName: 'Emily', surname: 'Davis', slackId: 'U999', + projects: [], standupResults: [{ rating: 3 }, { rating: 1 }, { rating: 1 }, { rating: 3 }], }, 'Test Event'); assert(result5 !== null, 'Should flag student with consecutive low scores in the middle'); @@ -96,6 +121,7 @@ const result6 = findConsecutiveLowScores({ givenName: 'Frank', surname: 'Miller', slackId: null, + projects: [], standupResults: [{ rating: null }, { rating: 1 }, { rating: 1 }], }, 'Test Event'); assert(result6 !== null, 'Should flag when consecutive low scores exist (ignoring nulls)'); @@ -106,6 +132,7 @@ const result7 = findConsecutiveLowScores({ givenName: 'Grace', surname: 'Lee', slackId: null, + projects: [], standupResults: [{ rating: null }, { rating: null }], }, 'Test Event'); assertEqual(result7, null, 'Should NOT flag when only null ratings exist'); @@ -118,11 +145,18 @@ const formatted1 = formatStudentList([{ givenName: 'Alice', surname: 'Smith', slackId: 'U123456', + assignedMentors: [ + { + givenName: 'Morgan', + surname: 'Lee', + slackId: 'UMENTOR1', + }, + ], eventName: 'Test Event', consecutiveLowScores: 2, lastTwoRatings: [1, 1], }]); -assertEqual(formatted1, '• <@U123456> (Alice Smith)', 'Should format with Slack mention'); +assertEqual(formatted1, '• <@U123456> (Alice Smith) - Mentor: <@UMENTOR1>', 'Should format with Slack mention'); // Test 9: Format single student without Slack ID const formatted2 = formatStudentList([{ @@ -130,11 +164,12 @@ const formatted2 = formatStudentList([{ givenName: 'Bob', surname: 'Jones', slackId: null, + assignedMentors: [], eventName: 'Test Event', consecutiveLowScores: 2, lastTwoRatings: [0, 1], }]); -assertEqual(formatted2, '• Bob Jones (Bob Jones)', 'Should format without Slack mention'); +assertEqual(formatted2, '• Bob Jones (Bob Jones) - Mentor: Unassigned', 'Should format without Slack mention'); // Test 10: Format multiple students const formatted3 = formatStudentList([ @@ -143,6 +178,13 @@ const formatted3 = formatStudentList([ givenName: 'Alice', surname: 'Smith', slackId: 'U123', + assignedMentors: [ + { + givenName: 'Morgan', + surname: 'Lee', + slackId: 'UMENTOR1', + }, + ], eventName: 'Test Event', consecutiveLowScores: 2, lastTwoRatings: [1, 1], @@ -152,12 +194,172 @@ const formatted3 = formatStudentList([ givenName: 'Bob', surname: 'Jones', slackId: null, + assignedMentors: [ + { + givenName: 'Taylor', + surname: 'Ng', + slackId: null, + }, + ], eventName: 'Test Event', consecutiveLowScores: 2, lastTwoRatings: [0, 1], }, ]); -const expected = '• <@U123> (Alice Smith)\n• Bob Jones (Bob Jones)'; +const expected = '• <@U123> (Alice Smith) - Mentor: <@UMENTOR1>\n• Bob Jones (Bob Jones) - Mentor: Taylor Ng'; assertEqual(formatted3, expected, 'Should format multiple students with newlines'); -console.log('\n✨ All tests passed!\n'); +console.log('\n🧪 Testing Slack channel lookup...\n'); + +async function testSlackChannelLookup() { + const slackToken = process.env.SLACK_BOT_TOKEN; + + if (!slackToken) { + console.log('⏭️ Skipping Slack tests (set SLACK_BOT_TOKEN to test)\n'); + console.log('✨ All non-Slack tests passed!\n'); + return; + } + + try { + const slack = new WebClient(slackToken); + + // Test 1: List channels + console.log('Fetching channel list from Slack...'); + const channelsList = await slack.conversations.list({ + exclude_archived: true, + types: 'public_channel,private_channel', + limit: 100, + }); + + assert( + Array.isArray(channelsList.channels) && channelsList.channels.length > 0, + 'Should retrieve list of channels from Slack' + ); + + // Test 2: Find a specific channel + const testChannelName = 'stats'; + const channel = channelsList.channels?.find( + (c: any) => c.name === testChannelName + ); + + assert( + channel !== undefined, + `Should find #${testChannelName} channel in workspace` + ); + + if (channel) { + console.log(`✅ Found channel: #${channel.name} (ID: ${channel.id})`); + } + + // Test 3: Handle non-existent channel + const nonExistentChannel = channelsList.channels?.find( + (c: any) => c.name === 'this-channel-definitely-does-not-exist-xyz123' + ); + + assertEqual( + nonExistentChannel, + undefined, + 'Should NOT find a non-existent channel' + ); + + console.log('\n✨ All tests passed (including Slack)!\n'); + } catch (error: any) { + console.error('\n❌ Slack test failed:'); + console.error(` Error: ${error.message}`); + if (error.code === 'invalid_auth') { + console.error(' The SLACK_BOT_TOKEN provided is invalid or expired.'); + } + process.exit(1); + } +} + +console.log('\n🧪 Testing database access...\n'); + +async function testDatabaseAccess() { + const databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl) { + console.log('⏭️ Skipping database tests (DATABASE_URL not set in .env)\n'); + return; + } + + try { + registerDi(); + const prisma = Container.get(PrismaClient); + + // Test 1: Connect and query an event + console.log('Connecting to database...'); + const eventCount = await prisma.event.count(); + assert(eventCount >= 0, 'Should connect to database and count events'); + console.log(`✅ Found ${eventCount} events in database`); + + // Test 2: Find an active event with Slack integration + console.log('\nLooking for active event with Slack integration...'); + const event = await prisma.event.findFirst({ + where: { + isActive: true, + slackWorkspaceAccessToken: { not: null }, + slackWorkspaceId: { not: null }, + }, + }); + + if (event) { + console.log(`✅ Found active event: ${event.name} (${event.id})`); + console.log( + ` Slack workspace: ${event.slackWorkspaceId}` + ); + } else { + console.log( + '⚠️ No active events with Slack integration found (this is OK for testing)' + ); + } + + // Test 3: Query students for standup scores + console.log('\nQuerying students with standup results...'); + const studentsWithScores = await prisma.student.findMany({ + where: { + standupResults: { + some: { + rating: { lt: 2 }, + }, + }, + }, + include: { + standupResults: { + orderBy: { createdAt: 'desc' }, + take: 2, + }, + }, + take: 5, + }); + + console.log( + `✅ Found ${studentsWithScores.length} students with low standup scores` + ); + + await prisma.$disconnect(); + console.log('\n✨ All database tests passed!\n'); + } catch (error: any) { + console.error('\n❌ Database test failed:'); + console.error(` Error: ${error.message}`); + if (error.message.includes('ECONNREFUSED')) { + console.error(' Cannot connect to database. Ensure it\'s running.'); + } else if (error.message.includes('authentication')) { + console.error(' Database authentication failed. Check DATABASE_URL.'); + } + process.exit(1); + } +} + +async function runAllTests() { + try { + await testSlackChannelLookup(); + await testDatabaseAccess(); + console.log('✨ All tests completed!\n'); + } catch (error) { + console.error('\n❌ Test suite failed:', error); + process.exit(1); + } +} + +runAllTests(); diff --git a/src/automation/tasks/weeklyLowStandupReport.ts b/src/automation/tasks/weeklyLowStandupReport.ts index 72af1c0..d0ad4b1 100644 --- a/src/automation/tasks/weeklyLowStandupReport.ts +++ b/src/automation/tasks/weeklyLowStandupReport.ts @@ -16,6 +16,11 @@ interface StudentWithLowStandups { givenName: string; surname: string; slackId: string | null; + assignedMentors: { + givenName: string; + surname: string; + slackId: string | null; + }[]; eventName: string; consecutiveLowScores: number; lastTwoRatings: (number | null)[]; @@ -78,6 +83,18 @@ async function sendReportForEvent( givenName: true, surname: true, slackId: true, + projects: { + select: { + mentors: { + select: { + id: true, + givenName: true, + surname: true, + slackId: true, + }, + }, + }, + }, standupResults: { where: { thread: { @@ -135,6 +152,14 @@ export function findConsecutiveLowScores( givenName: string; surname: string; slackId: string | null; + projects?: { + mentors: { + id: string; + givenName: string; + surname: string; + slackId: string | null; + }[]; + }[]; standupResults: { rating: number | null }[]; }, eventName: string @@ -149,11 +174,23 @@ export function findConsecutiveLowScores( const next = ratings[i + 1]; if (current !== null && next !== null && current < 2 && next < 2) { + const mentorById = new Map(); + for (const project of student.projects || []) { + for (const mentor of project.mentors) { + mentorById.set(mentor.id, { + givenName: mentor.givenName, + surname: mentor.surname, + slackId: mentor.slackId, + }); + } + } + return { studentId: student.id, givenName: student.givenName, surname: student.surname, slackId: student.slackId, + assignedMentors: Array.from(mentorById.values()), eventName, consecutiveLowScores: 2, lastTwoRatings: [current, next], @@ -173,7 +210,12 @@ export function formatStudentList(students: StudentWithLowStandups[]): string { return students .map(s => { const slackMention = s.slackId ? `<@${s.slackId}>` : `${s.givenName} ${s.surname}`; - return `• ${slackMention} (${s.givenName} ${s.surname})`; + const mentorLabel = s.assignedMentors.length > 0 + ? s.assignedMentors + .map((m) => (m.slackId ? `<@${m.slackId}>` : `${m.givenName} ${m.surname}`)) + .join(', ') + : 'Unassigned'; + return `• ${slackMention} (${s.givenName} ${s.surname}) - Mentor: ${mentorLabel}`; }) .join('\n'); } From 07b3eada5169c65a7a6a10e74d7617b74f7c1807 Mon Sep 17 00:00:00 2001 From: Hannah Cotterell Date: Tue, 21 Jul 2026 12:17:32 -0700 Subject: [PATCH 6/7] adds seeding of data for actual testing, adds slackbot testing. --- scripts/testWeeklyStandupReport.ts | 319 ++++++++++++++++-- .../tasks/weeklyLowStandupReport.ts | 41 ++- 2 files changed, 313 insertions(+), 47 deletions(-) diff --git a/scripts/testWeeklyStandupReport.ts b/scripts/testWeeklyStandupReport.ts index 35eb6b1..33cfe70 100644 --- a/scripts/testWeeklyStandupReport.ts +++ b/scripts/testWeeklyStandupReport.ts @@ -19,19 +19,24 @@ */ import 'reflect-metadata'; -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, MentorStatus, ProjectStatus, StudentStatus, Track } from '@prisma/client'; import Container from 'typedi'; import { WebClient } from '@slack/web-api'; import { DateTime } from 'luxon'; -import { formatStudentList } from '../src/automation/tasks/weeklyLowStandupReport'; +import { formatStudentList, getFlaggedStudentsForEvent } from '../src/automation/tasks/weeklyLowStandupReport'; import { registerDi } from '../src/di'; // Parse command line arguments const args = process.argv.slice(2); const isDryRun = args.includes('--dry-run'); const useRealData = args.includes('--use-real-data'); +const seedTestData = args.includes('--seed-test-data'); const channelArg = args.find(arg => arg.startsWith('--channel=')); -const channelName = channelArg ? channelArg.split('=')[1] : 'stats'; +const channelName = channelArg ? channelArg.split('=')[1] : 'all-codeday-testing'; + +const TEST_EVENT_ID = 'weekly-standup-report-test-event'; +const TEST_EVENT_NAME = 'Weekly Standup Report Test Event'; +const TEST_SLACK_BOT_TOKEN = process.env.TEST_SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN || null; interface TestStudent { studentId: string; @@ -90,6 +95,228 @@ const FAKE_STUDENTS: TestStudent[] = [ }, ]; +async function seedLocalTestData(prisma: PrismaClient): Promise<{ eventId: string; eventName: string; hasSlackToken: boolean }> { + const now = DateTime.now(); + let slackWorkspaceId: string | null = null; + + if (TEST_SLACK_BOT_TOKEN) { + const slack = new WebClient(TEST_SLACK_BOT_TOKEN); + const auth = await slack.auth.test(); + slackWorkspaceId = auth.team_id || null; + } + + await prisma.standupResult.deleteMany({ where: { eventId: TEST_EVENT_ID } }); + await prisma.standupThread.deleteMany({ where: { eventId: TEST_EVENT_ID } }); + await prisma.project.deleteMany({ where: { eventId: TEST_EVENT_ID } }); + await prisma.student.deleteMany({ where: { eventId: TEST_EVENT_ID } }); + await prisma.mentor.deleteMany({ where: { eventId: TEST_EVENT_ID } }); + await prisma.event.deleteMany({ where: { id: TEST_EVENT_ID } }); + + await prisma.event.create({ + data: { + id: TEST_EVENT_ID, + name: TEST_EVENT_NAME, + title: TEST_EVENT_NAME, + certificationStatements: [], + studentApplicationsStartAt: now.minus({ days: 30 }).toJSDate(), + mentorApplicationsStartAt: now.minus({ days: 30 }).toJSDate(), + studentApplicationsEndAt: now.minus({ days: 20 }).toJSDate(), + mentorApplicationsEndAt: now.minus({ days: 20 }).toJSDate(), + startsAt: now.minus({ days: 14 }).toJSDate(), + projectWorkStartsAt: now.minus({ days: 10 }).toJSDate(), + studentApplicationSchema: {}, + studentApplicationUi: {}, + studentApplicationPostprocess: {}, + mentorApplicationSchema: {}, + mentorApplicationUi: {}, + mentorApplicationPostprocess: {}, + isActive: true, + slackWorkspaceAccessToken: TEST_SLACK_BOT_TOKEN, + slackWorkspaceId, + }, + }); + + await prisma.mentor.createMany({ + data: [ + { + id: 'test-mentor-1', + eventId: TEST_EVENT_ID, + givenName: 'Morgan', + surname: 'Lee', + email: 'morgan.lee@example.test', + profile: {}, + status: MentorStatus.ACCEPTED, + }, + { + id: 'test-mentor-2', + eventId: TEST_EVENT_ID, + givenName: 'Riley', + surname: 'Park', + email: 'riley.park@example.test', + profile: {}, + status: MentorStatus.ACCEPTED, + }, + ], + }); + + await prisma.student.createMany({ + data: [ + { + id: 'test-student-1', + eventId: TEST_EVENT_ID, + givenName: 'Alice', + surname: 'TestStudent', + email: 'alice.teststudent@example.test', + profile: {}, + track: Track.BEGINNER, + status: StudentStatus.ACCEPTED, + minHours: 5, + }, + { + id: 'test-student-2', + eventId: TEST_EVENT_ID, + givenName: 'Bob', + surname: 'DemoUser', + email: 'bob.demouser@example.test', + profile: {}, + track: Track.BEGINNER, + status: StudentStatus.ACCEPTED, + minHours: 5, + }, + { + id: 'test-student-3', + eventId: TEST_EVENT_ID, + givenName: 'Charlie', + surname: 'SampleStudent', + email: 'charlie.samplestudent@example.test', + profile: {}, + track: Track.BEGINNER, + status: StudentStatus.ACCEPTED, + minHours: 5, + }, + ], + }); + + await prisma.project.create({ + data: { + id: 'test-project-1', + eventId: TEST_EVENT_ID, + description: 'Mentored project for weekly report testing', + deliverables: 'Weekly standups', + track: Track.BEGINNER, + status: ProjectStatus.MATCHED, + mentors: { + connect: [{ id: 'test-mentor-1' }, { id: 'test-mentor-2' }], + }, + students: { + connect: [{ id: 'test-student-1' }, { id: 'test-student-2' }], + }, + }, + }); + + await prisma.project.create({ + data: { + id: 'test-project-2', + eventId: TEST_EVENT_ID, + description: 'Unassigned project for weekly report testing', + deliverables: 'Weekly standups', + track: Track.BEGINNER, + status: ProjectStatus.MATCHED, + students: { + connect: [{ id: 'test-student-3' }], + }, + }, + }); + + await prisma.standupThread.createMany({ + data: [ + { + id: 'test-standup-thread-1', + dueAt: now.startOf('day').minus({ days: 6 }).toJSDate(), + projectId: 'test-project-1', + eventId: TEST_EVENT_ID, + }, + { + id: 'test-standup-thread-2', + dueAt: now.startOf('day').minus({ days: 3 }).toJSDate(), + projectId: 'test-project-1', + eventId: TEST_EVENT_ID, + }, + { + id: 'test-standup-thread-3', + dueAt: now.startOf('day').minus({ days: 5 }).toJSDate(), + projectId: 'test-project-2', + eventId: TEST_EVENT_ID, + }, + { + id: 'test-standup-thread-4', + dueAt: now.startOf('day').minus({ days: 2 }).toJSDate(), + projectId: 'test-project-2', + eventId: TEST_EVENT_ID, + }, + ], + }); + + await prisma.standupResult.createMany({ + data: [ + { + eventId: TEST_EVENT_ID, + projectId: 'test-project-1', + studentId: 'test-student-1', + threadId: 'test-standup-thread-1', + text: 'Low rating test standup 1', + rating: 1, + }, + { + eventId: TEST_EVENT_ID, + projectId: 'test-project-1', + studentId: 'test-student-1', + threadId: 'test-standup-thread-2', + text: 'Low rating test standup 2', + rating: 1, + }, + { + eventId: TEST_EVENT_ID, + projectId: 'test-project-1', + studentId: 'test-student-2', + threadId: 'test-standup-thread-1', + text: 'Low rating test standup 3', + rating: 0, + }, + { + eventId: TEST_EVENT_ID, + projectId: 'test-project-1', + studentId: 'test-student-2', + threadId: 'test-standup-thread-2', + text: 'Low rating test standup 4', + rating: 1, + }, + { + eventId: TEST_EVENT_ID, + projectId: 'test-project-2', + studentId: 'test-student-3', + threadId: 'test-standup-thread-3', + text: 'Low rating test standup 5', + rating: 1, + }, + { + eventId: TEST_EVENT_ID, + projectId: 'test-project-2', + studentId: 'test-student-3', + threadId: 'test-standup-thread-4', + text: 'Low rating test standup 6', + rating: 0, + }, + ], + }); + + return { + eventId: TEST_EVENT_ID, + eventName: TEST_EVENT_NAME, + hasSlackToken: Boolean(TEST_SLACK_BOT_TOKEN && slackWorkspaceId), + }; +} + async function postTestMessage( slack: WebClient | null, channelName: string, @@ -182,43 +409,73 @@ async function main() { let students: TestStudent[]; let eventName: string; - if (useRealData) { - // TODO: Implement real data fetching - console.log('⚠️ Real data mode not yet implemented, using fake data'); - students = FAKE_STUDENTS; - eventName = 'CodeDay Labs Test'; - } else { - students = FAKE_STUDENTS; - eventName = 'CodeDay Labs Test'; - } - - if (isDryRun) { - await postTestMessage(null, channelName, students, eventName); - return; - } - registerDi(); const prisma = Container.get(PrismaClient); try { - const event = await prisma.event.findFirst({ - where: { - isActive: true, - slackWorkspaceAccessToken: { not: null }, - slackWorkspaceId: { not: null }, - }, - }); + let seededEventInfo: { eventId: string; eventName: string; hasSlackToken: boolean } | null = null; + if (seedTestData) { + console.log('Seeding local weekly report test data...'); + seededEventInfo = await seedLocalTestData(prisma); + console.log(`✅ Seeded event: ${seededEventInfo.eventName} (${seededEventInfo.eventId})`); + if (!seededEventInfo.hasSlackToken) { + console.log('⚠️ Seeded DB data, but no test Slack bot token was found. Live posting will still require TEST_SLACK_BOT_TOKEN or SLACK_BOT_TOKEN.'); + } + } + + if (useRealData) { + const sourceEvent = seededEventInfo + ? await prisma.event.findUnique({ where: { id: seededEventInfo.eventId } }) + : await prisma.event.findFirst({ + where: { + isActive: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + if (!sourceEvent) { + console.error('❌ No event found to pull report data from. Seed data first with --seed-test-data.'); + process.exit(1); + } + + eventName = sourceEvent.name; + students = await getFlaggedStudentsForEvent(prisma, sourceEvent.id, sourceEvent.name); + console.log(`Using event data: ${sourceEvent.name} (${sourceEvent.id})`); + console.log(`Found ${students.length} flagged students in local DB\n`); + } else { + students = FAKE_STUDENTS; + eventName = 'CodeDay Labs Test'; + } + + if (isDryRun) { + await postTestMessage(null, channelName, students, eventName); + return; + } + + const event = seededEventInfo + ? await prisma.event.findUnique({ where: { id: seededEventInfo.eventId } }) + : await prisma.event.findFirst({ + where: { + isActive: true, + slackWorkspaceAccessToken: { not: null }, + slackWorkspaceId: { not: null }, + }, + orderBy: { + updatedAt: 'desc', + }, + }); - if (!event) { - console.error('❌ No active event with Slack integration found'); + if (!event?.slackWorkspaceAccessToken || !event.slackWorkspaceId) { + console.error('❌ No active event with Slack workspace token found. Seed a test event with TEST_SLACK_BOT_TOKEN or SLACK_BOT_TOKEN.'); process.exit(1); } - console.log(`Using event: ${event.name} (${event.id})\n`); + console.log(`Using Slack event: ${event.name} (${event.id})\n`); - // Create Slack client - const slack = new WebClient(event.slackWorkspaceAccessToken!, { - teamId: event.slackWorkspaceId!, + const slack = new WebClient(event.slackWorkspaceAccessToken, { + teamId: event.slackWorkspaceId, }); await postTestMessage(slack, channelName, students, eventName); diff --git a/src/automation/tasks/weeklyLowStandupReport.ts b/src/automation/tasks/weeklyLowStandupReport.ts index d0ad4b1..63d9509 100644 --- a/src/automation/tasks/weeklyLowStandupReport.ts +++ b/src/automation/tasks/weeklyLowStandupReport.ts @@ -65,17 +65,36 @@ async function sendReportForEvent( const prisma = Container.get(PrismaClient); const slack = getSlackClientForEvent(event); + const flaggedStudents = await getFlaggedStudentsForEvent(prisma, event.id, event.name); + + DEBUG(`Found ${flaggedStudents.length} students with consecutive low standup scores`); + + if (flaggedStudents.length === 0) { + DEBUG('No students to report, skipping Slack message.'); + return; + } + + // Post to #stats channel + await postToStatsChannel(slack, event.name, flaggedStudents); +} + +export async function getFlaggedStudentsForEvent( + prisma: PrismaClient, + eventId: string, + eventName: string, + now: DateTime = DateTime.now() +): Promise { + // Calculate date range for "previous week" (last 7 days from start of today) - const now = DateTime.now(); const startOfToday = now.startOf('day'); const oneWeekAgo = startOfToday.minus({ days: 7 }); - DEBUG(`Checking standups from ${oneWeekAgo.toISO()} to ${startOfToday.toISO()} for event ${event.id}`); + DEBUG(`Checking standups from ${oneWeekAgo.toISO()} to ${startOfToday.toISO()} for event ${eventId}`); // Get all students in the active event const students = await prisma.student.findMany({ where: { - eventId: event.id, + eventId, status: StudentStatus.ACCEPTED, }, select: { @@ -122,22 +141,12 @@ async function sendReportForEvent( }, }); - DEBUG(`Found ${students.length} accepted students in event ${event.id}`); + DEBUG(`Found ${students.length} accepted students in event ${eventId}`); // Filter students with two consecutive standup scores < 2 - const flaggedStudents = students - .map(student => findConsecutiveLowScores(student, event.name)) + return students + .map(student => findConsecutiveLowScores(student, eventName)) .filter((student): student is StudentWithLowStandups => student !== null); - - DEBUG(`Found ${flaggedStudents.length} students with consecutive low standup scores`); - - if (flaggedStudents.length === 0) { - DEBUG('No students to report, skipping Slack message.'); - return; - } - - // Post to #stats channel - await postToStatsChannel(slack, event.name, flaggedStudents); } /** From ead99f71615cce05b2d8fbb839cd6493a6d19be8 Mon Sep 17 00:00:00 2001 From: Tyler Menezes Date: Tue, 21 Jul 2026 14:35:50 -0700 Subject: [PATCH 7/7] Fix test loading in automation --- src/activities/tasks/index.ts | 27 +++++++++++++++++---------- src/automation/tasks/index.ts | 31 +++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/activities/tasks/index.ts b/src/activities/tasks/index.ts index 33e377e..4fe83a4 100644 --- a/src/activities/tasks/index.ts +++ b/src/activities/tasks/index.ts @@ -2,25 +2,32 @@ /* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable global-require */ /* eslint-disable import/no-dynamic-require */ -import fs from 'fs'; +import fs from "fs"; import { makeDebug } from "../../utils"; -const DEBUG = makeDebug('activities:tasks'); +const DEBUG = makeDebug("activities:tasks"); -type TaskImport = { default: Function, SCHEMA?: object | null }; -export type TaskExport = { name: string, fn: Function, schema: object | null } +type TaskImport = { default: Function; SCHEMA?: object | null }; +export type TaskExport = { name: string; fn: Function; schema: object | null }; -const allTasks = fs.readdirSync(__dirname) - .filter(n => !['index.ts', 'index.js'].includes(n)) - .map(n => { +const allTasks = fs + .readdirSync(__dirname) + .filter( + (n) => + !["index.ts", "index.js"].includes(n) && + !n.endsWith("test.js") && + !n.endsWith("test.ts"), + ) + .map((n) => { const f = require(`./${n}`) as TaskImport; - if (!f.default) throw new Error(`Task ${n} does not include a default export.`); + if (!f.default) + throw new Error(`Task ${n} does not include a default export.`); return { - name: n.replace(/\.(ts|js)$/g, ''), + name: n.replace(/\.(ts|js)$/g, ""), schema: f.SCHEMA || null, fn: f.default, }; }); export default allTasks; -DEBUG(`Loaded ${allTasks.length} activity tasks.`); \ No newline at end of file +DEBUG(`Loaded ${allTasks.length} activity tasks.`); diff --git a/src/automation/tasks/index.ts b/src/automation/tasks/index.ts index cb77828..ca4141e 100644 --- a/src/automation/tasks/index.ts +++ b/src/automation/tasks/index.ts @@ -2,25 +2,36 @@ /* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable global-require */ /* eslint-disable import/no-dynamic-require */ -import fs from 'fs'; +import fs from "fs"; import { makeDebug } from "../../utils"; -const DEBUG = makeDebug('automation:tasks'); +const DEBUG = makeDebug("automation:tasks"); -type TaskImport = { default: Function, JOBSPEC: string | undefined }; -export type TaskExport = { name: string, fn: Function, spec: string | undefined } +type TaskImport = { default: Function; JOBSPEC: string | undefined }; +export type TaskExport = { + name: string; + fn: Function; + spec: string | undefined; +}; -const allTasks = fs.readdirSync(__dirname) - .filter(n => !['index.ts', 'index.js'].includes(n)) - .map(n => { +const allTasks = fs + .readdirSync(__dirname) + .filter( + (n) => + !["index.ts", "index.js"].includes(n) && + !n.endsWith("test.js") && + !n.endsWith("test.ts"), + ) + .map((n) => { const f = require(`./${n}`) as TaskImport; - if (!f.default) throw new Error(`Task ${n} does not include a default export.`); + if (!f.default) + throw new Error(`Task ${n} does not include a default export.`); return { - name: n.replace(/\.(ts|js)$/g, ''), + name: n.replace(/\.(ts|js)$/g, ""), fn: f.default, spec: f.JOBSPEC, }; }); export default allTasks; -DEBUG(`Loaded ${allTasks.length} automation tasks.`); \ No newline at end of file +DEBUG(`Loaded ${allTasks.length} automation tasks.`);