From 36a6b5ccf882963d3794ef6627cda9442dc0512c Mon Sep 17 00:00:00 2001 From: Hannah Cotterell Date: Tue, 4 Aug 2026 14:49:42 -0700 Subject: [PATCH] Changes design so that slackid is stored and retrieved from db value. Timing set to run at 9am PST. --- package.json | 4 +- .../migration.sql | 1 + .../migration.sql | 1 + prisma/schema.prisma | 1 + scripts/backfillReportingChannel.ts | 97 +++++++++++++++++++ scripts/exportAll.ts | 22 ++--- scripts/testWeeklyStandupReport.ts | 14 +-- src/automation/index.ts | 5 +- .../weeklyLowStandupReport.manual-test.ts | 22 ++--- .../tasks/weeklyLowStandupReport.ts | 46 ++++----- src/inputs/EventEditInput.ts | 4 + src/resolvers/Event.ts | 23 ++++- src/slack/findSlackChannelByName.ts | 8 ++ src/slack/index.ts | 1 + src/types/Event.ts | 10 +- tests/testSlackReporting.ts | 27 ++++++ 16 files changed, 215 insertions(+), 71 deletions(-) create mode 100644 prisma/migrations/20260802113000_add_low_standup_channel_id/migration.sql create mode 100644 prisma/migrations/20260802113000_add_reporting_channel_id/migration.sql create mode 100644 scripts/backfillReportingChannel.ts create mode 100644 src/slack/findSlackChannelByName.ts create mode 100644 tests/testSlackReporting.ts diff --git a/package.json b/package.json index b52f2f6..6955ab8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dev": "ts-node-dev --no-notify --respawn --transpile-only src", "debug": "ts-node-dev --no-notify --respawn src", "send-event-recommendations": "ts-node scripts/sendEventRecommendations.ts", + "backfill-reporting-channel": "ts-node scripts/backfillReportingChannel.ts", + "test:slack-reporting": "node --test --require ts-node/register/transpile-only tests/testSlackReporting.ts", "swagger": "rm src/badgr/Api.ts; swagger-typescript-api -p badgr-api-v2.yaml -n Api2.ts -o ./src/badgr; echo 'type json = JSON;' | cat - src/badgr/Api2.ts > src/badgr/Api.ts; rm src/badgr/Api2.ts" }, "dependencies": { @@ -88,4 +90,4 @@ "ts-node-dev": "^1.1.6", "typescript": "^5.2.2" } -} +} \ No newline at end of file diff --git a/prisma/migrations/20260802113000_add_low_standup_channel_id/migration.sql b/prisma/migrations/20260802113000_add_low_standup_channel_id/migration.sql new file mode 100644 index 0000000..52608fc --- /dev/null +++ b/prisma/migrations/20260802113000_add_low_standup_channel_id/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Event" ADD COLUMN "slackReportingChannelId" TEXT; \ No newline at end of file diff --git a/prisma/migrations/20260802113000_add_reporting_channel_id/migration.sql b/prisma/migrations/20260802113000_add_reporting_channel_id/migration.sql new file mode 100644 index 0000000..52608fc --- /dev/null +++ b/prisma/migrations/20260802113000_add_reporting_channel_id/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Event" ADD COLUMN "slackReportingChannelId" TEXT; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 62a873a..3679e46 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -120,6 +120,7 @@ model Event { slackWorkspaceId String? slackUserGroupId String? slackAnnouncementChannelId String? + slackReportingChannelId String? slackMentorChannelId String? slackWorkspaceAccessToken String? standupAndProsperToken String? diff --git a/scripts/backfillReportingChannel.ts b/scripts/backfillReportingChannel.ts new file mode 100644 index 0000000..97011e4 --- /dev/null +++ b/scripts/backfillReportingChannel.ts @@ -0,0 +1,97 @@ +import 'reflect-metadata'; +import { PrismaClient } from '@prisma/client'; +import Container from 'typedi'; +import { registerDi } from '../src/di'; +import { getSlackClientForEvent, resolveSlackChannelId } from '../src/slack'; + +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); +const force = args.includes('--force'); +const channelArg = args.find(arg => arg.startsWith('--channel=')); +const channelName = channelArg ? channelArg.split('=')[1] : 'stats'; + +type EventRow = { + id: string; + name: string; + slackWorkspaceId: string; + slackWorkspaceAccessToken: string; + slackReportingChannelId: string | null; +}; + +async function main() { + registerDi(); + const prisma = Container.get(PrismaClient); + + const events = await prisma.event.findMany({ + where: { + isActive: true, + slackWorkspaceAccessToken: { not: null }, + slackWorkspaceId: { not: null }, + }, + select: { + id: true, + name: true, + slackWorkspaceId: true, + slackWorkspaceAccessToken: true, + slackReportingChannelId: true, + }, + }) as EventRow[]; + + console.log(`Found ${events.length} active events with Slack integration.`); + console.log(`Target channel name: #${channelName}`); + console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}${force ? ' (force overwrite enabled)' : ''}`); + + let updated = 0; + let skippedConfigured = 0; + let skippedNotFound = 0; + let failed = 0; + + for (const event of events) { + if (event.slackReportingChannelId && !force) { + skippedConfigured += 1; + console.log(`- ${event.id}: already configured (${event.slackReportingChannelId}), skipping`); + continue; + } + + try { + const slack = getSlackClientForEvent(event); + const channelId = await resolveSlackChannelId(slack, event.slackReportingChannelId); + + if (!channelId) { + skippedNotFound += 1; + console.log(`- ${event.id}: no configured reporting channel ID, skipping`); + continue; + } + + if (dryRun) { + updated += 1; + console.log(`- ${event.id}: would set slackReportingChannelId=${channelId}`); + continue; + } + + await prisma.event.update({ + where: { id: event.id }, + data: { slackReportingChannelId: channelId } as any, + }); + updated += 1; + console.log(`- ${event.id}: set slackReportingChannelId=${channelId}`); + } catch (error: any) { + failed += 1; + console.log(`- ${event.id}: failed (${error?.message || error})`); + } + } + + console.log(''); + console.log('Summary:'); + console.log(`updated: ${updated}`); + console.log(`skipped (already configured): ${skippedConfigured}`); + console.log(`skipped (channel not found): ${skippedNotFound}`); + console.log(`failed: ${failed}`); + + await prisma.$disconnect(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/exportAll.ts b/scripts/exportAll.ts index b6c3837..4d38bd2 100644 --- a/scripts/exportAll.ts +++ b/scripts/exportAll.ts @@ -8,7 +8,7 @@ import { anonymousId } from '../src/utils'; import { DateTime } from 'luxon'; function dropFields>(toDrop: string[]): (model: T) => Partial { - return function(model: T): Partial { + return function (model: T): Partial { const out = { ...model }; for (const field of toDrop) { delete out[field]; @@ -21,7 +21,7 @@ const LOW_INCOME_PARTNER_CODES = []; const LOW_INCOME_SCHOOLS = []; function anonymizeProfile(profileField: string): (model: T) => T { - return function(model: T): T { + return function (model: T): T { const profileValue = model[profileField]; const out = { ...model }; delete out[profileField]; @@ -39,7 +39,7 @@ function anonymizeProfile(profileField: string): (model: T) => } function anonymizeModel(type: string): (model: T) => T { - return function(model: T): T { + return function (model: T): T { // const out = { ...model }; for (const field of Object.keys(model)) { @@ -63,8 +63,8 @@ function anonymizeModel(type: string): (model: T) => T { } } -function anonymizeMappingModel(aType: string, bType: string): (mode: T) => T { - return function(model: T): T { +function anonymizeMappingModel(aType: string, bType: string): (mode: T) => T { + return function (model: T): T { return { ...model, A: ['event', 'tag', 'eventId', 'tagId'].includes(aType) ? model.A : anonymousId(aType, model.A), @@ -96,12 +96,12 @@ async function dumpTables(prisma: PrismaClient, eventIdFilter: Prisma.StringFilt artifactType: (await prisma.artifactType.findMany({ where: filter })) .map(anonymizeModel('artifactType')), - emailSent: (await prisma.emailSent.findMany({ where: { OR: [{ project: filter }, { student: filter }, { mentor: filter }]} })) + emailSent: (await prisma.emailSent.findMany({ where: { OR: [{ project: filter }, { student: filter }, { mentor: filter }] } })) .map(anonymizeModel('emailSent')), event: (await prisma.event.findMany({ where: { id: eventIdFilter } })) .map(anonymizeModel('event')) - .map(dropFields(['emailSignature', 'certificationStatements', 'slackWorkspaceId', 'slackUserGroupId', 'slackMentorChannelId', 'slackWorkspaceAccessToken', 'standupAndProsperToken', 'standupAiModelVaguePending', 'standupAiModelWorkloadPending', ])), + .map(dropFields(['emailSignature', 'certificationStatements', 'slackWorkspaceId', 'slackUserGroupId', 'slackAnnouncementChannelId', 'slackReportingChannelId', 'slackMentorChannelId', 'slackWorkspaceAccessToken', 'standupAndProsperToken', 'standupAiModelVaguePending', 'standupAiModelWorkloadPending',])), meeting: (await prisma.meeting.findMany({ where: filter })) .map(anonymizeModel('meeting')), @@ -157,16 +157,16 @@ async function dumpTables(prisma: PrismaClient, eventIdFilter: Prisma.StringFilt .map(anonymizeModel('tagTrainingSubmission')) .map(dropFields('url')), - _mentorToProject: (await prisma.$queryRaw<{A: string, B: string}[]>`SELECT "A", "B" FROM "_MentorToProject";`) + _mentorToProject: (await prisma.$queryRaw<{ A: string, B: string }[]>`SELECT "A", "B" FROM "_MentorToProject";`) .map(anonymizeMappingModel('mentor', 'project')), - _projectToStudent: (await prisma.$queryRaw<{A: string, B: string}[]>`SELECT "A", "B" FROM "_ProjectToStudent";`) + _projectToStudent: (await prisma.$queryRaw<{ A: string, B: string }[]>`SELECT "A", "B" FROM "_ProjectToStudent";`) .map(anonymizeMappingModel('project', 'student')), - _projectToTag: (await prisma.$queryRaw<{A: string, B: string}[]>`SELECT "A", "B" FROM "_ProjectToTag";`) + _projectToTag: (await prisma.$queryRaw<{ A: string, B: string }[]>`SELECT "A", "B" FROM "_ProjectToTag";`) .map(anonymizeMappingModel('project', 'tag')), - _studentToTag: (await prisma.$queryRaw<{A: string, B: string}[]>`SELECT "A", "B" FROM "_StudentToTag";`) + _studentToTag: (await prisma.$queryRaw<{ A: string, B: string }[]>`SELECT "A", "B" FROM "_StudentToTag";`) .map(anonymizeMappingModel('student', 'tag')), }; } diff --git a/scripts/testWeeklyStandupReport.ts b/scripts/testWeeklyStandupReport.ts index 33cfe70..e3a76af 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, getFlaggedStudentsForEvent } from '../src/automation/tasks/weeklyLowStandupReport'; +import { findSlackChannelByName } from '../src/slack'; import { registerDi } from '../src/di'; // Parse command line arguments @@ -373,21 +374,10 @@ async function postTestMessage( 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 - ); + const channel = await findSlackChannelByName(slack, 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; } diff --git a/src/automation/index.ts b/src/automation/index.ts index 1ccba82..d221518 100644 --- a/src/automation/index.ts +++ b/src/automation/index.ts @@ -4,6 +4,7 @@ import tasks from './tasks'; import { makeDebug } from "../utils"; const DEBUG = makeDebug('automation'); +const AUTOMATION_TIMEZONE = 'America/Los_Angeles'; const tasksByName = Object.fromEntries( tasks.map(t => [t.name, t]) @@ -35,8 +36,8 @@ export async function startAutomation() { tasks .filter(t => t.spec) .forEach(({ spec, fn, name }) => { - DEBUG(`Registered task ${name} for ${spec}.`); - new CronJob(spec!, tryCrontab(fn), null, true) + DEBUG(`Registered task ${name} for ${spec} in ${AUTOMATION_TIMEZONE}.`); + new CronJob(spec!, tryCrontab(fn), null, true, AUTOMATION_TIMEZONE) }) } diff --git a/src/automation/tasks/weeklyLowStandupReport.manual-test.ts b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts index d5792fa..f99a9ef 100644 --- a/src/automation/tasks/weeklyLowStandupReport.manual-test.ts +++ b/src/automation/tasks/weeklyLowStandupReport.manual-test.ts @@ -22,6 +22,7 @@ import { WebClient } from '@slack/web-api'; import { PrismaClient } from '@prisma/client'; import { registerDi } from '../../di'; import Container from 'typedi'; +import { findSlackChannelByName } from '../../slack'; // Simple assertion helper function assert(condition: boolean, message: string) { @@ -224,23 +225,11 @@ async function testSlackChannelLookup() { 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' - ); + console.log('Searching for channel in Slack...'); // Test 2: Find a specific channel const testChannelName = 'stats'; - const channel = channelsList.channels?.find( - (c: any) => c.name === testChannelName - ); + const channel = await findSlackChannelByName(slack, testChannelName); assert( channel !== undefined, @@ -252,8 +241,9 @@ async function testSlackChannelLookup() { } // Test 3: Handle non-existent channel - const nonExistentChannel = channelsList.channels?.find( - (c: any) => c.name === 'this-channel-definitely-does-not-exist-xyz123' + const nonExistentChannel = await findSlackChannelByName( + slack, + 'this-channel-definitely-does-not-exist-xyz123' ); assertEqual( diff --git a/src/automation/tasks/weeklyLowStandupReport.ts b/src/automation/tasks/weeklyLowStandupReport.ts index 63d9509..8f36f71 100644 --- a/src/automation/tasks/weeklyLowStandupReport.ts +++ b/src/automation/tasks/weeklyLowStandupReport.ts @@ -1,10 +1,9 @@ import { PrismaClient, StudentStatus } from "@prisma/client"; import Container from "typedi"; import { getSlackClientForEvent } from "../../slack"; -import { makeDebug, PickNonNullable } from "../../utils"; +import { makeDebug } from "../../utils"; import { DateTime } from "luxon"; import { WebClient } from "@slack/web-api"; -import { Event } from "@prisma/client"; const DEBUG = makeDebug('automation:tasks:weeklyLowStandupReport'); @@ -26,6 +25,14 @@ interface StudentWithLowStandups { lastTwoRatings: (number | null)[]; } +type WeeklyReportEvent = { + id: string; + name: string; + slackWorkspaceAccessToken: string; + slackWorkspaceId: string; + slackReportingChannelId: string | null; +}; + export default async function weeklyLowStandupReport(): Promise { const prisma = Container.get(PrismaClient); @@ -43,8 +50,9 @@ export default async function weeklyLowStandupReport(): Promise { name: true, slackWorkspaceAccessToken: true, slackWorkspaceId: true, + slackReportingChannelId: true, }, - }) as (PickNonNullable & Pick)[]; + }) as WeeklyReportEvent[]; DEBUG(`Found ${events.length} active events with Slack integration.`); @@ -60,7 +68,7 @@ export default async function weeklyLowStandupReport(): Promise { } async function sendReportForEvent( - event: PickNonNullable & Pick + event: WeeklyReportEvent ): Promise { const prisma = Container.get(PrismaClient); const slack = getSlackClientForEvent(event); @@ -75,7 +83,12 @@ async function sendReportForEvent( } // Post to #stats channel - await postToStatsChannel(slack, event.name, flaggedStudents); + await postToStatsChannel( + slack, + event.name, + flaggedStudents, + event.slackReportingChannelId, + ); } export async function getFlaggedStudentsForEvent( @@ -232,35 +245,24 @@ export function formatStudentList(students: StudentWithLowStandups[]): string { async function postToStatsChannel( slack: WebClient, eventName: string, - students: StudentWithLowStandups[] + students: StudentWithLowStandups[], + configuredChannelId: string | null, ): 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.`); + if (!configuredChannelId) { + DEBUG(`No configured reporting channel ID for this event; skipping report.`); return; } - DEBUG(`Found channel #${STATS_CHANNEL_NAME} with ID ${statsChannel.id}`); + DEBUG(`Using configured reporting channel ID ${configuredChannelId}`); // Format the message const studentList = formatStudentList(students); await slack.chat.postMessage({ - channel: statsChannel.id!, + channel: configuredChannelId, blocks: [ { type: 'header', diff --git a/src/inputs/EventEditInput.ts b/src/inputs/EventEditInput.ts index 0c4dfcb..0175f21 100644 --- a/src/inputs/EventEditInput.ts +++ b/src/inputs/EventEditInput.ts @@ -79,6 +79,9 @@ export class EventEditInput { @Field(() => String, { nullable: true }) slackWorkspaceAccessToken?: string | null + @Field(() => String, { nullable: true }) + slackReportingChannelId?: string | null + public toQuery(): Prisma.EventUpdateInput { return { name: this.name ?? undefined, @@ -109,6 +112,7 @@ export class EventEditInput { partnersOnly: this.partnersOnly ?? undefined, slackWorkspaceId: this.slackWorkspaceId ?? undefined, slackWorkspaceAccessToken: this.slackWorkspaceAccessToken ?? undefined, + slackReportingChannelId: this.slackReportingChannelId ?? undefined, }; } } \ No newline at end of file diff --git a/src/resolvers/Event.ts b/src/resolvers/Event.ts index 6936d97..d37c493 100644 --- a/src/resolvers/Event.ts +++ b/src/resolvers/Event.ts @@ -13,7 +13,7 @@ import { nameToSlug } from '../utils'; @Resolver(Event) export class EventResolver { @Inject(() => PrismaClient) - private readonly prisma : PrismaClient; + private readonly prisma: PrismaClient; @Authorized() @Query(() => Event, { nullable: true }) @@ -46,6 +46,20 @@ export class EventResolver { }); } + @Authorized(AuthRole.ADMIN) + @Mutation(() => Event) + async setSlackReportingChannel( + @Ctx() { auth }: Context, + @Arg('channelId', () => String) channelId: string, + ): Promise { + return this.prisma.event.update({ + where: { id: auth.eventId! }, + data: { + slackReportingChannelId: channelId, + }, + }); + } + @Authorized(AuthRole.ADMIN) @Mutation(() => Event) async cloneEvent( @@ -132,6 +146,7 @@ export class EventResolver { slackWorkspaceId: source.slackWorkspaceId, slackWorkspaceAccessToken: source.slackWorkspaceAccessToken, slackAnnouncementChannelId: source.slackAnnouncementChannelId, + slackReportingChannelId: source.slackReportingChannelId, slackMentorChannelId: null, slackUserGroupId: null, standupAndProsperToken: source.standupAndProsperToken, @@ -143,7 +158,7 @@ export class EventResolver { }); if (source.fileTypes.length > 0) { - for(const ft of source.fileTypes) { + for (const ft of source.fileTypes) { await this.prisma.fileType.create({ data: { slug: ft.slug, @@ -159,7 +174,7 @@ export class EventResolver { } if (source.surveys.length > 0) { - for(const s of source.surveys) { + for (const s of source.surveys) { const survey = await this.prisma.survey.create({ data: { name: s.name, @@ -309,7 +324,7 @@ export class EventResolver { })), }); } - + return event; } } diff --git a/src/slack/findSlackChannelByName.ts b/src/slack/findSlackChannelByName.ts new file mode 100644 index 0000000..7bce746 --- /dev/null +++ b/src/slack/findSlackChannelByName.ts @@ -0,0 +1,8 @@ +import { WebClient } from '@slack/web-api'; + +export async function resolveSlackChannelId( + _slack: Pick, + configuredChannelId: string | null, +): Promise { + return configuredChannelId; +} \ No newline at end of file diff --git a/src/slack/index.ts b/src/slack/index.ts index d8e0f0d..6f3f0b5 100644 --- a/src/slack/index.ts +++ b/src/slack/index.ts @@ -1,6 +1,7 @@ export * from './addMissingSlackChannelMembers'; export * from './archiveSlackChannels'; export * from './createSlackChannels'; +export * from './findSlackChannelByName'; export * from './getSlackClientForEvent'; export * from './linkExistingSlackChannels'; export * from './linkExistingSlackMembers'; diff --git a/src/types/Event.ts b/src/types/Event.ts index 274ee1a..02ed49b 100644 --- a/src/types/Event.ts +++ b/src/types/Event.ts @@ -94,6 +94,10 @@ export class Event { @Field(() => String, { nullable: true }) slackWorkspaceAccessToken: string | null + @Authorized(AuthRole.ADMIN) + @Field(() => String, { nullable: true }) + slackReportingChannelId: string | null + @Authorized(AuthRole.ADMIN) @Field(() => String, { nullable: true }) standupAndProsperToken: string | null @@ -112,13 +116,13 @@ export class Event { @Field(() => GraphQLJSONObject, { nullable: true }) mentorApplicationSchema?: JSONSchema7 - + @Field(() => GraphQLJSONObject, { nullable: true }) mentorApplicationUi?: Record @Field(() => GraphQLJSONObject, { nullable: true }) studentApplicationSchema?: JSONSchema7 - + @Field(() => GraphQLJSONObject, { nullable: true }) studentApplicationUi?: Record @@ -136,7 +140,7 @@ export class Event { @Ctx() { auth }: Context, ): Promise { if (!(auth.isAuthenticated || auth.isUnspecified)) return false; - return (await Container.get(PrismaClient) + return (await Container.get(PrismaClient) .student.count({ where: { ...auth.toWhereMany()!, eventId: this.id } })) > 0; } diff --git a/tests/testSlackReporting.ts b/tests/testSlackReporting.ts new file mode 100644 index 0000000..a9d484e --- /dev/null +++ b/tests/testSlackReporting.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveSlackChannelId } from '../src/slack/findSlackChannelByName'; + +test('returns a configured channel ID without doing any lookup', async () => { + const slack = { + paginate: async () => { + throw new Error('paginate should not be called when a configured channel id exists'); + }, + } as any; + + const result = await resolveSlackChannelId(slack, 'C123'); + + assert.equal(result, 'C123'); +}); + +test('returns null when no configured channel ID exists', async () => { + const slack = { + paginate: async () => { + throw new Error('paginate should not be called when no configured channel id exists'); + }, + } as any; + + const result = await resolveSlackChannelId(slack, null); + + assert.equal(result, null); +});