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
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"restore-from-backup": "esr scripts/restore-from-backup.ts",
"shoutout-leaderboard": "esr scripts/shoutout-leaderboard.ts",
"create-member-archive": "esr scripts/create-member-archive.ts",
"populate-coffee-chat-suggestions": "esr scripts/populate-coffee-chat-suggestions.ts",
"populate-coffee-chat-categories": "esr scripts/populate-coffee-chat-categories.ts",
"upload-alumni": "esr scripts/upload-alumni-data-db.ts"
},
"license": "AGPL-3.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
/// <reference types="common-types" />
import admin from 'firebase-admin';
import fs from 'fs';
import COFFEE_CHAT_BINGO_BOARD from '../src/consts';

import { configureAccount } from '../src/utils/firebase-utils';

Expand Down Expand Up @@ -37,13 +36,11 @@ const filteredSuggestions = (
const getMembersByCategory = async (members: IdolMember[]) => {
const memberByNetID = new Map(members.map((m) => [m.netid.trim().toLowerCase(), m] as const));

// Update csv path to current semester suggestions
const csv = fs.readFileSync('./scripts/sp26-coffee-chat-bingo.csv').toString();
const rows = csv.split(/\r?\n/);

let responses = rows.splice(1);

// Remove duplicates by NetID and keep latest submission
const seenNetIds = new Set<string>();
responses = responses
.reverse()
Expand All @@ -57,10 +54,10 @@ const getMembersByCategory = async (members: IdolMember[]) => {
})
.reverse();

// Note: This script handles basic name capitalization and duplicate removal,
// but manual CSV review may still be needed for unaccountable name formatting issues

const board = COFFEE_CHAT_BINGO_BOARD.flat();
const board = rows[0]
.split(',')
.slice(3)
.map((c) => c.trim());
const suggestions: CoffeeChatSuggestions = {};

const OFFSET = 3;
Expand All @@ -80,38 +77,48 @@ const getMembersByCategory = async (members: IdolMember[]) => {

suggestions['a newbie'] = filteredSuggestions(
members,
(mem) => mem.semesterJoined === 'Fall 2025'
(mem) => mem.semesterJoined === 'Spring 2026'
);

const alumniSnapshot = await db.collection('alumni').where('gradYear', '==', 2025).get();
const alumniMembers: MemberDetails[] = alumniSnapshot.docs.map((doc) => {
const alum = doc.data() as Alumni;
return { name: `${alum.firstName} ${alum.lastName}`, netid: alum.uuid };
});
const existingAlumNetIds = new Set(
(suggestions['a DTI alum'] ?? []).map((m) => m.netid.trim().toLowerCase())
);
suggestions['a DTI alum'] = [
...(suggestions['a DTI alum'] ?? []),
...alumniMembers.filter((m) => !existingAlumNetIds.has(m.netid.trim().toLowerCase()))
];

return suggestions;
return board.map(
(name, index): CoffeeChatCategory => ({ name, members: suggestions[name], index })
);
};

const main = async () => {
const members = await memberPromise;
const membersByCategory = await getMembersByCategory(members);
const categories = await getMembersByCategory(members);

/* commented out for now - no need to filter self from categories
const filterSelfFromCategories = (mem: IdolMember) =>
Object.fromEntries(
Object.entries(membersByCategory).map(([key, value]) => [
Object.entries(categories).map(([key, value]) => [
key,
(value as MemberDetails[]).filter((details) => details.netid !== mem.netid)
])
);
*/

const ids = await db
.collection('coffee-chat-suggestions')
.get()
.then((val) => val.docs.map((doc) => doc.id));

await Promise.all(ids.map((id) => db.collection('coffee-chat-suggestions').doc(id).delete()));
const batch = db.batch();
for (const category of categories) {
batch.set(db.collection('coffee-chat-categories').doc(String(category.index)), category);
}
await batch.commit();

await Promise.all(
members.map((mem) =>
db.collection('coffee-chat-suggestions').doc(mem.email).create(membersByCategory)
)
);
console.log(`Successfully uploaded ${categories.length} categories to coffee-chat-categories.`);
};

main();
main().catch(console.error);
23 changes: 11 additions & 12 deletions backend/scripts/sp26-coffee-chat-bingo.csv
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
sp26-coffee-chat-bingo
Timestamp,Full Name,NetId,a DTI alum,plays an instrument,boba > coffee,switched majors,from west coast,can cook a signature dish,Over 6ft,been on DTI for > 3 sem,studied abroad,has been to more than 3 countries,a newbie,GymRat / GymBaddie,likes anime,worked at a startup,not from the US,has over 3 siblings
2/6/2026 23:05:36,,cl2683,no,no,no,yes,yes,yes,no,yes,no,yes,no,yes,no,no,no,no
2/7/2026 13:57:40,,ajq22,no,no,no,no,no,yes,yes,yes,no,yes,no,no,no,no,no,no
Expand Down Expand Up @@ -41,20 +40,20 @@ Timestamp,Full Name,NetId,a DTI alum,plays an instrument,boba > coffee,switched
2/11/2026 18:16:11,,jpg285,no,no,yes,yes,no,no,no,no,no,no,no,no,no,yes,yes,yes
2/11/2026 19:11:10,,gsh76,no,yes,yes,no,no,yes,no,no,no,no,no,yes,no,no,no,no
2/11/2026 22:02:17,,may52,no,no,no,yes,no,no,no,yes,yes,yes,no,no,yes,yes,yes,no
2/19/2026 19:12:08,,cy562,no,no,yes,no,yes,no,no,no,no,yes,no,no,yes,no,no,no
2/19/2026 20:20:15,,kh862,no,yes,no,yes,no,no,no,no,no,yes,no,no,no,no,no,no
2/19/2026 20:35:42,,ek782,no,yes,yes,no,no,no,no,no,no,yes,no,yes,no,no,no,no
2/19/2026 19:12:08,,cy562,no,no,yes,no,yes,no,no,no,no,yes,yes,no,yes,no,no,no
2/19/2026 20:20:15,,kh862,no,yes,no,yes,no,no,no,no,no,yes,yes,no,no,no,no,no
2/19/2026 20:35:42,,ek782,no,yes,yes,no,no,no,no,no,no,yes,yes,yes,no,no,no,no
2/19/2026 22:16:48,,Gl532,no,no,no,yes,no,no,yes,no,no,no,no,yes,yes,no,no,no
2/20/2026 15:32:29,,yz2395,no,no,yes,no,no,no,no,no,no,yes,no,no,no,no,yes,no
2/20/2026 15:32:29,,yz2395,no,no,yes,no,no,no,no,no,no,yes,yes,no,no,no,yes,no
2/21/2026 14:02:04,,hhs74,no,yes,yes,yes,no,no,no,no,no,yes,no,no,no,no,yes,no
2/21/2026 14:02:35,,wc724,no,no,yes,no,no,no,no,no,no,no,no,no,yes,yes,no,no
2/21/2026 14:03:08,,hv96,no,no,yes,yes,no,no,no,no,no,yes,no,no,no,no,no,no
2/21/2026 14:02:35,,wc724,no,no,yes,no,no,no,no,no,no,no,yes,no,yes,yes,no,no
2/21/2026 14:03:08,,hv96,no,no,yes,yes,no,no,no,no,no,yes,yes,no,no,no,no,no
2/21/2026 14:04:19,,nmp79,no,yes,yes,yes,no,yes,no,yes,no,yes,no,no,no,yes,no,no
2/21/2026 15:05:28,,sm2883,no,yes,yes,yes,no,no,no,no,no,no,no,yes,no,no,no,no
2/21/2026 16:25:52,,wc679,no,no,yes,no,no,no,no,no,no,no,no,no,no,no,no,no
2/22/2026 0:30:32,,bs887,no,no,yes,no,yes,no,yes,no,no,yes,no,yes,no,no,no,no
2/22/2026 1:42:47,,as4465,no,no,yes,no,yes,yes,yes,no,no,yes,no,yes,yes,no,no,no
2/23/2026 19:47:59,,ak2835,no,yes,no,no,no,yes,no,no,yes,yes,no,no,no,no,yes,no
2/21/2026 15:05:28,,sm2883,no,yes,yes,yes,no,no,no,no,no,no,yes,yes,no,no,no,no
2/21/2026 16:25:52,,wc679,no,no,yes,no,no,no,no,no,no,no,yes,no,no,no,no,no
2/22/2026 0:30:32,,bs887,no,no,yes,no,yes,no,yes,no,no,yes,yes,yes,no,no,no,no
2/22/2026 1:42:47,,as4465,no,no,yes,no,yes,yes,yes,no,no,yes,yes,yes,yes,no,no,no
2/23/2026 19:47:59,,ak2835,no,yes,no,no,no,yes,no,no,yes,yes,yes,no,no,no,yes,no
2/24/2026 21:16:38,,zw757,no,no,no,yes,no,no,no,yes,no,yes,no,no,no,no,yes,no
3/2/2026 10:43:16,,sg2626,no,no,no,no,yes,no,no,no,no,no,yes,no,yes,yes,no,no
3/2/2026 18:09:53,,drk229,no,yes,yes,yes,no,yes,no,yes,no,yes,no,no,yes,no,yes,no
Expand Down
95 changes: 84 additions & 11 deletions backend/src/API/coffeeChatAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { Request } from 'express';
import CoffeeChatDao from '../dao/CoffeeChatDao';
import PermissionsManager from '../utils/permissionsManager';
import { BadRequestError, PermissionError } from '../utils/errors';
import { getMember } from './memberAPI';
import { getMember, allApprovedMembers } from './memberAPI';
import { sendCoffeeChatReminder } from './mailAPI';
import parseCoffeeChatCSV from '../utils/coffeeChatCSVParser';

const coffeeChatDao = new CoffeeChatDao();

Expand Down Expand Up @@ -160,18 +161,78 @@ export const getCoffeeChatBingoBoard = (): Promise<string[][]> =>
CoffeeChatDao.getCoffeeChatBingoBoard();

/**
* Gets coffee chat suggestions for a specifc member
* @param email - the email of the member
* @returns A promise that resolves to a CoffeeChatSuggestions object.
* Gets coffee chat suggestions for a specific member, excluding themselves from each category
* @param email - the email of the member requesting suggestions
* @returns a CoffeeChatSuggestions object
*/
export const getCoffeeChatSuggestions = async (email: string): Promise<CoffeeChatSuggestions> => {
const suggestions = await CoffeeChatDao.getCoffeeChatSuggestions(email);
if (!suggestions) {
throw new BadRequestError(
`Coffee chat suggestions does not exist for member with email ${email}`
const [member, suggestions] = await Promise.all([
getMember(email),
CoffeeChatDao.getCoffeeChatSuggestions()
]);

if (!member) return suggestions;

const selfNetid = member.netid.trim().toLowerCase();
return Object.fromEntries(
Object.entries(suggestions).map(([category, members]) => [
category,
members.filter((m) => m.netid.trim().toLowerCase() !== selfNetid)
])
);
};

/**
* Gets all coffee chat categories
* @param user - the user making the request
* @returns an array of sorted CoffeeChatCategory objects
*/
export const getCoffeeCategories = async (user: IdolMember): Promise<CoffeeChatCategory[]> => {
const isLeadOrAdmin = await PermissionsManager.isLeadOrAdmin(user);
if (!isLeadOrAdmin) {
throw new PermissionError(
`User with email ${user.email} does not have permission to view coffee chat categories.`
);
}
return CoffeeChatDao.getAllCategories();
};

/**
* Updates the members list for a single coffee chat category
* @param index - the index of the category to update
* @param members - the new members list for this category
* @param user - the user making the request
*/
export const updateCategoryMembers = async (
index: number,
members: MemberDetails[],
user: IdolMember
): Promise<void> => {
const isLeadOrAdmin = await PermissionsManager.isLeadOrAdmin(user);
if (!isLeadOrAdmin) {
throw new PermissionError(
`User with email ${user.email} does not have permission to update coffee chat categories.`
);
}
await CoffeeChatDao.updateCategoryMembers(index, members);
};

/**
* Parses a CSV string and updates all coffee chat categories
* @param csvContent - CSV string
* @param user - the user making the request
*/
export const uploadCoffeeChatCSV = async (csvContent: string, user: IdolMember): Promise<void> => {
const isLeadOrAdmin = await PermissionsManager.isLeadOrAdmin(user);
if (!isLeadOrAdmin) {
throw new PermissionError(
`User with email ${user.email} does not have permission to upload coffee chat categories.`
);
}
return suggestions;

const members = await allApprovedMembers();
const categories = parseCoffeeChatCSV(csvContent, [...members]);
await CoffeeChatDao.updateCategories(categories);
};

/**
Expand Down Expand Up @@ -231,7 +292,7 @@ export const runAutoChecker = async (uuid: string, user: IdolMember): Promise<Co
* @param otherMemberEmail - the email of the member we are checking.
* @param submitterEmail - the email of the member that submitted the coffee chat.
* @param category - the category we are checking with.
* @returns 'pass' if a member meets a category, 'fail' if not, 'no data' if not enough data to know.
* @returns 'pass' if a member meets a category, 'fail' if not, 'no data' if the category doesn't exist.
*/
export const checkMemberMeetsCategory = async (
otherMemberEmail: string,
Expand All @@ -243,15 +304,27 @@ export const checkMemberMeetsCategory = async (
let status: MemberMeetsCategoryStatus = 'no data';
let message: string = '';

// If otherMember and submitter don't exist, status should stay undefined
if (otherMember && submitter) {
if (category === 'a newbie') {
status = otherMember.semesterJoined === 'Spring 2026' ? 'pass' : 'fail';
if (status === 'fail') {
message = `${otherMember.firstName} ${otherMember.lastName} is not a newbie`;
}
} else {
const categories = await CoffeeChatDao.getAllCategories();
const categoryDoc = categories.find((c) => c.name === category);
if (categoryDoc) {
const inCategory = categoryDoc.members.some(
(m) => m.netid.trim().toLowerCase() === otherMember.netid.trim().toLowerCase()
);
status = inCategory ? 'pass' : 'fail';
if (status === 'fail') {
message = `${otherMember.firstName} ${otherMember.lastName} does not meet the category '${category}'`;
}
}
}
}

return { status, message };
};

Expand Down
19 changes: 18 additions & 1 deletion backend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ import {
runAutoChecker,
notifyMemberCoffeeChat,
getCoffeeChatSuggestions,
archiveCoffeeChats
archiveCoffeeChats,
getCoffeeCategories,
updateCategoryMembers,
uploadCoffeeChatCSV
} from './API/coffeeChatAPI';
import {
allSignInForms,
Expand Down Expand Up @@ -405,6 +408,20 @@ loginCheckedGet('/coffee-chat-suggestions/:email', async (req) => {
return { suggestions };
});

loginCheckedGet('/coffee-chat-categories', async (_, user) => ({
categories: await getCoffeeCategories(user)
}));

loginCheckedPut('/coffee-chat-categories/:index/members', async (req, user) => {
await updateCategoryMembers(Number(req.params.index), req.body.members, user);
return {};
});

loginCheckedPost('/coffee-chat-categories/upload', async (req, user) => {
await uploadCoffeeChatCSV(req.body.csv, user);
return {};
});

// Pull from IDOL
loginCheckedPost('/pullIDOLChanges', (_, user) => requestIDOLPullDispatch(user));
loginCheckedGet('/getIDOLChangesPR', (_, user) => getIDOLChangesPR(user));
Expand Down
9 changes: 0 additions & 9 deletions backend/src/consts.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
const COFFEE_CHAT_BINGO_BOARD = [
['a DTI alum', 'plays an instrument', 'boba > coffee', 'switched majors'],
['from west coast', 'can cook a signature dish', 'Over 6ft', 'been on DTI for > 3 sem'],
['studied abroad', 'has been to more than 3 countries', 'a newbie', 'GymRat / GymBaddie'],
['likes anime', 'worked at a startup', 'not from the US', 'has over 3 siblings']
];

export default COFFEE_CHAT_BINGO_BOARD;

export const DISABLE_DELETE_ALL_COFFEE_CHATS = true;

export const REQUIRED_MEMBER_TEC_CREDITS = 1;
Expand Down
Loading
Loading