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/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..33cfe70 --- /dev/null +++ b/scripts/testWeeklyStandupReport.ts @@ -0,0 +1,496 @@ +/** + * 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, MentorStatus, ProjectStatus, StudentStatus, Track } from '@prisma/client'; +import Container from 'typedi'; +import { WebClient } from '@slack/web-api'; +import { DateTime } from 'luxon'; +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] : '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; + givenName: string; + surname: string; + slackId: string | null; + assignedMentors: { + 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 + assignedMentors: [{ + givenName: 'Morgan', + surname: 'Lee', + slackId: null, + }], + eventName: 'CodeDay Labs Test', + consecutiveLowScores: 2, + lastTwoRatings: [1, 1], + }, + { + studentId: 'test-2', + givenName: 'Bob', + surname: 'DemoUser', + slackId: null, + assignedMentors: [{ + givenName: 'Riley', + surname: 'Park', + slackId: null, + }], + eventName: 'CodeDay Labs Test', + consecutiveLowScores: 2, + lastTwoRatings: [0, 1], + }, + { + studentId: 'test-3', + givenName: 'Charlie', + surname: 'SampleStudent', + slackId: null, + assignedMentors: [], + eventName: 'CodeDay Labs Test', + consecutiveLowScores: 2, + lastTwoRatings: [1, 0], + }, +]; + +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, + students: TestStudent[], + eventName: string +): Promise { + const studentList = formatStudentList(students); + + const message = { + channel: channelName, + 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'); + 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() { + 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`); + + let students: TestStudent[]; + let eventName: string; + + registerDi(); + const prisma = Container.get(PrismaClient); + + try { + 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?.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 Slack event: ${event.name} (${event.id})\n`); + + const slack = new WebClient(event.slackWorkspaceAccessToken, { + teamId: event.slackWorkspaceId, + }); + + await postTestMessage(slack, channelName, students, eventName); + } finally { + await prisma.$disconnect(); + } +} + +main() + .then(() => { + console.log('\n✨ Test complete'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Error:', error); + process.exit(1); + }); + 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.`); diff --git a/src/automation/tasks/weeklyLowStandupReport.manual-test.ts b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts new file mode 100644 index 0000000..d5792fa --- /dev/null +++ b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts @@ -0,0 +1,365 @@ +/** + * Manual test script for weeklyLowStandupReport + * + * 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 + * + * 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) { + 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', + projects: [], + 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', + 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({ + id: 'student-3', + 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'); + +// Test 4: Student with scores 2 or higher +const result4 = findConsecutiveLowScores({ + id: 'student-4', + 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'); + +// Test 5: Consecutive low scores in the middle +const result5 = findConsecutiveLowScores({ + id: 'student-5', + 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'); + +// Test 6: Handle null ratings +const result6 = findConsecutiveLowScores({ + id: 'student-6', + 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)'); + +// Test 7: Only null ratings +const result7 = findConsecutiveLowScores({ + id: 'student-7', + 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'); + +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', + assignedMentors: [ + { + givenName: 'Morgan', + surname: 'Lee', + slackId: 'UMENTOR1', + }, + ], + eventName: 'Test Event', + consecutiveLowScores: 2, + lastTwoRatings: [1, 1], +}]); +assertEqual(formatted1, '• <@U123456> (Alice Smith) - Mentor: <@UMENTOR1>', '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, + assignedMentors: [], + eventName: 'Test Event', + consecutiveLowScores: 2, + lastTwoRatings: [0, 1], +}]); +assertEqual(formatted2, '• Bob Jones (Bob Jones) - Mentor: Unassigned', 'Should format without Slack mention'); + +// Test 10: Format multiple students +const formatted3 = formatStudentList([ + { + studentId: 'student-1', + givenName: 'Alice', + surname: 'Smith', + slackId: 'U123', + assignedMentors: [ + { + givenName: 'Morgan', + surname: 'Lee', + slackId: 'UMENTOR1', + }, + ], + eventName: 'Test Event', + consecutiveLowScores: 2, + lastTwoRatings: [1, 1], + }, + { + studentId: 'student-2', + 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) - Mentor: <@UMENTOR1>\n• Bob Jones (Bob Jones) - Mentor: Taylor Ng'; +assertEqual(formatted3, expected, 'Should format multiple students with newlines'); + +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 new file mode 100644 index 0000000..63d9509 --- /dev/null +++ b/src/automation/tasks/weeklyLowStandupReport.ts @@ -0,0 +1,304 @@ +import { PrismaClient, StudentStatus } from "@prisma/client"; +import Container from "typedi"; +import { getSlackClientForEvent } from "../../slack"; +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'); + +// 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; + assignedMentors: { + 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, + }, + }) as (PickNonNullable & Pick)[]; + + 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: PickNonNullable & Pick +): Promise { + 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 startOfToday = now.startOf('day'); + const oneWeekAgo = startOfToday.minus({ days: 7 }); + + 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, + status: StudentStatus.ACCEPTED, + }, + select: { + id: true, + givenName: true, + surname: true, + slackId: true, + projects: { + select: { + mentors: { + 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 ${eventId}`); + + // Filter students with two consecutive standup scores < 2 + return students + .map(student => findConsecutiveLowScores(student, eventName)) + .filter((student): student is StudentWithLowStandups => student !== null); +} + +/** + * 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; + projects?: { + mentors: { + 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) { + 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], + }; + } + } + + 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}`; + 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'); +} + +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 = formatStudentList(students); + + await slack.chat.postMessage({ + 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}`, + }, + ], + }, + ], + }); + + DEBUG(`Successfully posted report to #${STATS_CHANNEL_NAME}`); + } catch (error) { + DEBUG(`Error posting to #${STATS_CHANNEL_NAME}:`, error); + throw error; + } +}