Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -88,4 +90,4 @@
"ts-node-dev": "^1.1.6",
"typescript": "^5.2.2"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "Event" ADD COLUMN "slackReportingChannelId" TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "Event" ADD COLUMN "slackReportingChannelId" TEXT;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ model Event {
slackWorkspaceId String?
slackUserGroupId String?
slackAnnouncementChannelId String?
slackReportingChannelId String?
slackMentorChannelId String?
slackWorkspaceAccessToken String?
standupAndProsperToken String?
Expand Down
97 changes: 97 additions & 0 deletions scripts/backfillReportingChannel.ts
Original file line number Diff line number Diff line change
@@ -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);
});
22 changes: 11 additions & 11 deletions scripts/exportAll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { anonymousId } from '../src/utils';
import { DateTime } from 'luxon';

function dropFields<T extends Record<string, any>>(toDrop: string[]): (model: T) => Partial<T> {
return function(model: T): Partial<T> {
return function (model: T): Partial<T> {
const out = { ...model };
for (const field of toDrop) {
delete out[field];
Expand All @@ -21,7 +21,7 @@ const LOW_INCOME_PARTNER_CODES = [];
const LOW_INCOME_SCHOOLS = [];

function anonymizeProfile<T extends object>(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];
Expand All @@ -39,7 +39,7 @@ function anonymizeProfile<T extends object>(profileField: string): (model: T) =>
}

function anonymizeModel<T extends object>(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)) {
Expand All @@ -63,8 +63,8 @@ function anonymizeModel<T extends object>(type: string): (model: T) => T {
}
}

function anonymizeMappingModel<T extends {A: string, B: string}>(aType: string, bType: string): (mode: T) => T {
return function(model: T): T {
function anonymizeMappingModel<T extends { A: string, B: string }>(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),
Expand Down Expand Up @@ -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')),
Expand Down Expand Up @@ -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')),
};
}
Expand Down
14 changes: 2 additions & 12 deletions scripts/testWeeklyStandupReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
5 changes: 3 additions & 2 deletions src/automation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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)
})
}

Expand Down
22 changes: 6 additions & 16 deletions src/automation/tasks/weeklyLowStandupReport.manual-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Loading
Loading