diff --git a/apps/admin-dashboard/app/routes/_dashboard.applications.tsx b/apps/admin-dashboard/app/routes/_dashboard.applications.tsx index ec3905f8b..6931e1ced 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.applications.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.applications.tsx @@ -185,6 +185,7 @@ function ApplicationsTable() { .with('email_bounced', () => 'Email bounced') .with('ineligible_major', () => 'Not the right major') .with('is_international', () => 'Not enrolled in US or Canada') + .with('linkedin_already_used', () => 'LinkedIn already used') .with('not_undergraduate', () => 'Not an undergrad student') .with('other', () => 'Other') .otherwise(() => '-'); diff --git a/apps/admin-dashboard/app/routes/_dashboard.students.$id.remove.tsx b/apps/admin-dashboard/app/routes/_dashboard.students.$id.remove.tsx deleted file mode 100644 index f6aa8441b..000000000 --- a/apps/admin-dashboard/app/routes/_dashboard.students.$id.remove.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { - type ActionFunctionArgs, - Form, - type LoaderFunctionArgs, - redirect, - useLoaderData, -} from 'react-router'; - -import { job } from '@oyster/core/bull'; -import { db } from '@oyster/db'; -import { BooleanInput } from '@oyster/types'; -import { Button, Checkbox, Modal } from '@oyster/ui'; - -import { Route } from '@/shared/constants'; -import { - commitSession, - ensureUserAuthenticated, - toast, -} from '@/shared/session.server'; - -export async function loader({ params, request }: LoaderFunctionArgs) { - await ensureUserAuthenticated(request); - - const student = await db - .selectFrom('students') - .select(['firstName', 'lastName']) - .where('id', '=', params.id as string) - .executeTakeFirst(); - - if (!student) { - return redirect(Route['/students']); - } - - return { - student, - }; -} - -export async function action({ params, request }: ActionFunctionArgs) { - const session = await ensureUserAuthenticated(request); - - const student = await db - .deleteFrom('students') - .returning(['airtableId', 'email', 'firstName', 'slackId']) - .where('id', '=', params.id as string) - .executeTakeFirst(); - - if (!student) { - throw new Response(null, { status: 404 }); - } - - const form = await request.formData(); - - const sendViolationEmail = BooleanInput.parse(form.get('sendViolationEmail')); - - job('student.removed', { - airtableId: student.airtableId as string, - email: student.email, - firstName: student.firstName, - sendViolationEmail, - slackId: student.slackId, - }); - - toast(session, { - message: 'Removed member.', - }); - - return redirect(Route['/students'], { - headers: { - 'Set-Cookie': await commitSession(session), - }, - }); -} - -export default function RemoveMemberPage() { - const { student } = useLoaderData(); - - return ( - - - - Remove {student.firstName} {student.lastName} - - - - - - This is not an undoable action. All of their engagement records will be - deleted and they will be removed from Slack, Mailchimp and Airtable. Are - you sure want to remove this member? - - -
- - - - - - -
- ); -} - -export function ErrorBoundary() { - return <>; -} diff --git a/apps/admin-dashboard/app/routes/_dashboard.students.$id.status-update.tsx b/apps/admin-dashboard/app/routes/_dashboard.students.$id.status-update.tsx new file mode 100644 index 000000000..cd48aec83 --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.students.$id.status-update.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { + type ActionFunctionArgs, + Form, + type LoaderFunctionArgs, + redirect, + useLoaderData, +} from 'react-router'; + +import { job } from '@oyster/core/bull'; +import { db } from '@oyster/db'; +import { BooleanInput, MemberStatus } from '@oyster/types'; +import { Button, Checkbox, Field, Modal, Select } from '@oyster/ui'; + +import { Route } from '@/shared/constants'; +import { + commitSession, + ensureUserAuthenticated, + toast, +} from '@/shared/session.server'; + +export async function loader({ params, request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + const student = await db + .selectFrom('students') + .select(['firstName', 'lastName', 'status']) + .where('id', '=', params.id as string) + .executeTakeFirst(); + + if (!student) { + return redirect(Route['/students']); + } + + return { + student, + }; +} + +export async function action({ params, request }: ActionFunctionArgs) { + const session = await ensureUserAuthenticated(request); + + const student = await db + .selectFrom('students') + .select(['airtableId', 'email', 'firstName', 'lastName', 'id', 'slackId']) + .where('id', '=', params.id as string) + .executeTakeFirst(); + + if (!student) { + throw new Response(null, { status: 404 }); + } + + const form = await request.formData(); + + const status = form.get('status') as string; + const sendViolationEmail = + status === MemberStatus.INACTIVE + ? BooleanInput.parse(form.get('sendViolationEmail')) + : false; + + job('student.batch_update_status', { + memberIds: [student.id], + status: status as (typeof MemberStatus)[keyof typeof MemberStatus], + sendViolationEmail, + }); + + const statusLabel = status === MemberStatus.ACTIVE ? 'active' : 'inactive'; + + toast(session, { + message: `Marked member ${student.firstName} ${student.lastName} as ${statusLabel}.`, + }); + + return redirect(Route['/students'], { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); +} + +export default function UpdateStatusPage() { + const { student } = useLoaderData(); + + const [selectedStatus, setSelectedStatus] = useState( + student.status === MemberStatus.ACTIVE + ? MemberStatus.INACTIVE + : MemberStatus.ACTIVE + ); + + const isMarkingInactive = selectedStatus === MemberStatus.INACTIVE; + + return ( + + + + Update {student.firstName} {student.lastName} Status + + + + + + {isMarkingInactive + ? 'This will mark the member as inactive, which disables their access to the ColorStack platform, Mailchimp, and Slack. If they have violated the Code of Conduct and you would like to send a violation email, please check the box below.' + : 'This will mark the member as active, which restores their access to the ColorStack platform, Mailchimp, and Slack.'} + + +
+ + + + + {isMarkingInactive && ( + + )} + + + + + +
+ ); +} + +export function ErrorBoundary() { + return <>; +} diff --git a/apps/admin-dashboard/app/routes/_dashboard.students.remove.tsx b/apps/admin-dashboard/app/routes/_dashboard.students.remove.tsx index 1d82bf73a..4bad36d90 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.students.remove.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.students.remove.tsx @@ -9,6 +9,7 @@ import { import z from 'zod'; import { job } from '@oyster/core/bull'; +import { MemberStatus } from '@oyster/types'; import { Button, ErrorMessage, @@ -57,13 +58,14 @@ export async function action({ request }: ActionFunctionArgs) { const batches = splitArray(ids, 10); for (const batch of batches) { - job('student.batch_remove', { + job('student.batch_update_status', { + status: MemberStatus.BULK_REMOVED, memberIds: batch, }); } toast(session, { - message: `Removing ${ids.length} members asynchronously.`, + message: `Updating status of ${ids.length} members to ${MemberStatus.BULK_REMOVED} asynchronously.`, }); return redirect(Route['/students'], { @@ -84,13 +86,14 @@ export default function RemoveMembersPage() { - This action is not reversible. All of their engagement records will be - deleted and they will be removed from Slack, Mailchimp and Airtable. + User records will remain intact in our database and Airtable, while + access user account will be deactivated from Slack and Mailchimp will be + removed. Note: This process will run asynchronously and if there are a lot of - members to remove, it may take several hours to fully remove them from + members to update, it may take several hours to fully update them in Slack, Mailchimp and Airtable. diff --git a/apps/admin-dashboard/app/routes/_dashboard.students.tsx b/apps/admin-dashboard/app/routes/_dashboard.students.tsx index 63f588e19..f9ab81045 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.students.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.students.tsx @@ -5,8 +5,8 @@ import { Edit, ExternalLink, Menu, + RefreshCw, Star, - Trash, Trash2, } from 'react-feather'; import { @@ -16,14 +16,17 @@ import { Outlet, useLoaderData, } from 'react-router'; +import { match } from 'ts-pattern'; import { ListSearchParams } from '@oyster/core/admin-dashboard/ui'; import { db } from '@oyster/db'; +import { MemberStatus } from '@oyster/types'; import { Dashboard, Dropdown, IconButton, Pagination, + Pill, type SerializeFrom, Table, type TableColumnProps, @@ -81,6 +84,7 @@ async function listStudents({ 'students.email', 'students.firstName', 'students.id', + 'students.status', 'students.lastName', 'students.otherSchool', 'schools.name as schoolName', @@ -180,16 +184,35 @@ function StudentsTable() { ); }, - size: '240', + size: '200', }, { displayName: 'Email', - size: '320', + size: '240', render: (student) => student.email, }, + { + displayName: 'Status', + size: '160', + render: (student) => { + if (student.status === MemberStatus.BULK_REMOVED) { + return Bulk Removed; + } + + if (student.status === MemberStatus.BANNED) { + return Banned; + } + + if (student.status === MemberStatus.INACTIVE) { + return Inactive; + } + + return Active; + }, + }, { displayName: 'School', - size: '360', + size: '280', render: (student) => student.schoolName || student.otherSchool || '-', }, { @@ -273,9 +296,9 @@ function StudentDropdown({ airtableUri, applicationUri, id }: StudentInView) { - Remove Member + Update Status diff --git a/apps/admin-dashboard/app/shared/constants.ts b/apps/admin-dashboard/app/shared/constants.ts index 3b6701821..a1dd71ac4 100644 --- a/apps/admin-dashboard/app/shared/constants.ts +++ b/apps/admin-dashboard/app/shared/constants.ts @@ -44,7 +44,7 @@ const ROUTES = [ '/students/remove', '/students/:id/email', '/students/:id/points/grant', - '/students/:id/remove', + '/students/:id/status-update', ] as const; export type Route = (typeof ROUTES)[number]; diff --git a/apps/member-profile/app/routes/_profile.home.tsx b/apps/member-profile/app/routes/_profile.home.tsx index fa2e5018e..1918e5896 100644 --- a/apps/member-profile/app/routes/_profile.home.tsx +++ b/apps/member-profile/app/routes/_profile.home.tsx @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { type PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; import { ExternalLink, GitHub, @@ -312,6 +312,8 @@ function ActiveStatusCard() { const className = match(status.status) .with('active', () => 'bg-success') .with('inactive', () => 'bg-error') + .with('bulk_removed', () => 'bg-error') + .with('banned', () => 'bg-error') .with(undefined, () => 'bg-gray-200') .exhaustive(); @@ -582,7 +584,7 @@ function MerchStoreCard() { - + Shop Now diff --git a/apps/member-profile/app/shared/session.server.ts b/apps/member-profile/app/shared/session.server.ts index 5c75f33a9..7c1a79d47 100644 --- a/apps/member-profile/app/shared/session.server.ts +++ b/apps/member-profile/app/shared/session.server.ts @@ -4,6 +4,8 @@ import { type Session, } from 'react-router'; +import { getMemberById } from '@oyster/core/member-profile/server'; +import { MemberStatus } from '@oyster/types'; import { type ToastProps } from '@oyster/ui'; import { id } from '@oyster/utils'; @@ -64,6 +66,18 @@ export async function ensureUserAuthenticated( }); } + // Check if the member's status is still active + const memberId = session.get(SESSION.USER_ID); + const member = await getMemberById(memberId); + + if (!member || member.status !== MemberStatus.ACTIVE) { + throw redirect(redirectTo, { + headers: { + 'Set-Cookie': await destroySession(session), + }, + }); + } + return session; } diff --git a/packages/core/src/infrastructure/bull.types.ts b/packages/core/src/infrastructure/bull.types.ts index 2b1eb8860..3932c03da 100644 --- a/packages/core/src/infrastructure/bull.types.ts +++ b/packages/core/src/infrastructure/bull.types.ts @@ -6,6 +6,7 @@ import { Email, Event, type ExtractValue, + MemberStatus, ProfileView, Student, StudentEmail, @@ -472,6 +473,12 @@ export const SlackBullJob = z.discriminatedUnion('name', [ slackId: Student.shape.slackId.unwrap(), }), }), + z.object({ + name: z.literal('slack.activate'), + data: z.object({ + slackId: Student.shape.slackId.unwrap(), + }), + }), z.object({ name: z.literal('slack.emoji.changed'), data: z.discriminatedUnion('subtype', [ @@ -626,6 +633,14 @@ export const StudentBullJob = z.discriminatedUnion('name', [ memberIds: z.array(Student.shape.id), }), }), + z.object({ + name: z.literal('student.batch_update_status'), + data: z.object({ + memberIds: z.array(Student.shape.id), + status: z.nativeEnum(MemberStatus), + sendViolationEmail: z.boolean().optional(), + }), + }), z.object({ name: z.literal('student.birthdate.daily'), data: z.object({}), @@ -675,6 +690,18 @@ export const StudentBullJob = z.discriminatedUnion('name', [ slackId: Student.shape.slackId.nullable(), }), }), + z.object({ + name: z.literal('student.status_updated'), + data: z.object({ + airtableId: z.string().trim().min(1), + email: Student.shape.email, + firstName: z.string().trim().min(1), + sendViolationEmail: z.boolean(), + slackId: Student.shape.slackId.nullable(), + status: z.nativeEnum(MemberStatus), + studentId: Student.shape.id, + }), + }), z.object({ name: z.literal('student.statuses.backfill'), data: z.object({ diff --git a/packages/core/src/member-profile.server.ts b/packages/core/src/member-profile.server.ts index 5c9153271..6a1537a24 100644 --- a/packages/core/src/member-profile.server.ts +++ b/packages/core/src/member-profile.server.ts @@ -19,6 +19,7 @@ export { isFeatureFlagEnabled } from './modules/feature-flags/queries/is-feature export { getIcebreakerPrompts } from './modules/icebreakers/queries/get-icebreaker-prompts'; export { getIcebreakerResponses } from './modules/icebreakers/queries/get-icebreaker-responses'; export { upsertIcebreakerResponses } from './modules/icebreakers/use-cases/upsert-icebreaker-responses'; +export { getMemberById } from './modules/members/queries/get-member-by-id'; export { listEmails } from './modules/members/queries/list-emails'; export { listMembersInDirectory } from './modules/members/queries/list-members-in-directory'; export { addEmail, AddEmailInput } from './modules/members/use-cases/add-email'; diff --git a/packages/core/src/modules/applications/applications.ts b/packages/core/src/modules/applications/applications.ts index 67c1a4dd9..0df49ae61 100644 --- a/packages/core/src/modules/applications/applications.ts +++ b/packages/core/src/modules/applications/applications.ts @@ -3,7 +3,11 @@ import { type SelectExpression, sql } from 'kysely'; import { match } from 'ts-pattern'; import { type DB, db } from '@oyster/db'; -import { type Application, OtherDemographic } from '@oyster/types'; +import { + type Application, + MemberStatus, + OtherDemographic, +} from '@oyster/types'; import { id, run } from '@oyster/utils'; import { job, registerWorker } from '@/infrastructure/bull'; @@ -170,155 +174,214 @@ export async function acceptApplication( applicationId: string, adminId: string ) { - const application = await db - .selectFrom('applications') - .select([ - 'applications.createdAt', - 'applications.educationLevel', - 'applications.email', - 'applications.firstName', - 'applications.gender', - 'applications.graduationMonth', - 'applications.graduationYear', - 'applications.id', - 'applications.lastName', - 'applications.linkedInUrl', - 'applications.major', - 'applications.otherDemographics', - 'applications.otherMajor', - 'applications.otherSchool', - 'applications.race', - 'applications.referralId', - 'applications.schoolId', - ]) - .where('id', '=', applicationId) - .executeTakeFirstOrThrow(); - - let studentId = ''; - - await db.transaction().execute(async (trx) => { - await trx - .updateTable('applications') - .set({ - acceptedAt: new Date(), - reviewedById: adminId, - status: ApplicationStatus.ACCEPTED, - }) + try { + const application = await db + .selectFrom('applications') + .select([ + 'applications.createdAt', + 'applications.educationLevel', + 'applications.email', + 'applications.firstName', + 'applications.gender', + 'applications.graduationMonth', + 'applications.graduationYear', + 'applications.id', + 'applications.lastName', + 'applications.linkedInUrl', + 'applications.major', + 'applications.otherDemographics', + 'applications.otherMajor', + 'applications.otherSchool', + 'applications.race', + 'applications.referralId', + 'applications.schoolId', + ]) .where('id', '=', applicationId) - .execute(); + .executeTakeFirstOrThrow(); - if (application.referralId) { + let studentId = ''; + let existingStudent = false; + + await db.transaction().execute(async (trx) => { await trx - .updateTable('referrals') - .set({ status: ReferralStatus.ACCEPTED }) - .where('id', '=', application.referralId) + .updateTable('applications') + .set({ + acceptedAt: new Date(), + reviewedById: adminId, + status: ApplicationStatus.ACCEPTED, + }) + .where('id', '=', applicationId) .execute(); - } - // Some applicants apply multiple times to ColorStack (typically it's an - // accident) and historically we would _try_ to accept all of their - // applications, but we can't have multiple members with the same email - // so it would cause issues. We'll scrap any other pending applications - // from the same email address. - await trx - .deleteFrom('applications') - .where('email', '=', application.email) - .where('id', '!=', application.id) - .where('status', '=', ApplicationStatus.PENDING) - .execute(); - - await trx - .insertInto('studentEmails') - .values({ email: application.email }) - .execute(); - - const allOtherDemographics = Object.values(OtherDemographic) as string[]; - - const otherDemographics = application.otherDemographics.filter( - (demographic) => { - return !allOtherDemographics.includes(demographic); + if (application.referralId) { + await trx + .updateTable('referrals') + .set({ status: ReferralStatus.ACCEPTED }) + .where('id', '=', application.referralId) + .execute(); } - ); - studentId = id(); + // Some applicants apply multiple times to ColorStack (typically it's an + // accident) and historically we would _try_ to accept all of their + // applications, but we can't have multiple members with the same email + // so it would cause issues. We'll scrap any other pending applications + // from the same email address. + await trx + .deleteFrom('applications') + .where('email', '=', application.email) + .where('id', '!=', application.id) + .where('status', '=', ApplicationStatus.PENDING) + .execute(); - await trx - .insertInto('students') - .values({ - acceptedAt: new Date(), - applicationId: application.id, - appliedAt: application.createdAt, - educationLevel: application.educationLevel, - email: application.email, - firstName: application.firstName, - gender: application.gender, - graduationMonth: application.graduationMonth, - graduationYear: application.graduationYear.toString(), - id: studentId, - lastName: application.lastName, - linkedInUrl: application.linkedInUrl, - major: application.major, - otherDemographics, - otherMajor: application.otherMajor, - otherSchool: application.otherSchool, - race: application.race, - schoolId: application.schoolId, - }) - .execute(); + const allOtherDemographics = Object.values(OtherDemographic) as string[]; - await trx - .updateTable('studentEmails') - .set({ studentId }) - .where('email', '=', application.email) - .execute(); - }); + const otherDemographics = application.otherDemographics.filter( + (demographic) => { + return !allOtherDemographics.includes(demographic); + } + ); - job('student.created', { - studentId, - }); + // Check if there's an existing bulk_removed student to reactivate + const existingStudentId = await findBulkRemovedStudent(application.email); + + if (existingStudentId) { + existingStudent = true; + // Reactivate existing student with updated profile data + await trx + .updateTable('students') + .set({ + acceptedAt: new Date(), + applicationId: application.id, + appliedAt: application.createdAt, + educationLevel: application.educationLevel, + email: application.email, + firstName: application.firstName, + gender: application.gender, + graduationMonth: application.graduationMonth, + graduationYear: application.graduationYear.toString(), + lastName: application.lastName, + linkedInUrl: application.linkedInUrl, + major: application.major, + otherDemographics, + otherMajor: application.otherMajor, + otherSchool: application.otherSchool, + race: application.race, + schoolId: application.schoolId, + }) + .where('id', '=', existingStudentId) + .execute(); + + studentId = existingStudentId; + + // Add new email if it doesn't already exist + const existingEmail = await trx + .selectFrom('studentEmails') + .where('email', 'ilike', application.email) + .executeTakeFirst(); + + if (!existingEmail) { + await trx + .insertInto('studentEmails') + .values({ email: application.email, studentId }) + .execute(); + } + } else { + // Create new student + await trx + .insertInto('studentEmails') + .values({ email: application.email }) + .execute(); + + studentId = id(); + + await trx + .insertInto('students') + .values({ + acceptedAt: new Date(), + applicationId: application.id, + appliedAt: application.createdAt, + educationLevel: application.educationLevel, + email: application.email, + firstName: application.firstName, + gender: application.gender, + graduationMonth: application.graduationMonth, + graduationYear: application.graduationYear.toString(), + id: studentId, + lastName: application.lastName, + linkedInUrl: application.linkedInUrl, + major: application.major, + otherDemographics, + otherMajor: application.otherMajor, + otherSchool: application.otherSchool, + race: application.race, + schoolId: application.schoolId, + }) + .execute(); - job('notification.email.send', { - data: { firstName: application.firstName }, - name: 'application-accepted', - to: application.email, - }); + await trx + .updateTable('studentEmails') + .set({ studentId }) + .where('email', '=', application.email) + .execute(); + } + }); - job('student.linkedin.sync', { - memberIds: [studentId], - }); + if (!existingStudent) { + job('student.created', { + studentId, + }); + } - if (application.referralId) { - const referral = await db - .selectFrom('referrals') - .leftJoin('students as referrers', 'referrers.id', 'referrals.referrerId') - .select([ - 'referrals.firstName as referredFirstName', - 'referrals.lastName as referredLastName', - 'referrers.email as referrerEmail', - 'referrers.id as referrerId', - 'referrers.firstName as referrerFirstName', - ]) - .where('referrals.id', '=', application.referralId) - .executeTakeFirst(); + job('notification.email.send', { + data: { firstName: application.firstName }, + name: 'application-accepted', + to: application.email, + }); - if (referral) { - job('notification.email.send', { - data: { - firstName: referral.referrerFirstName as string, - referralsUri: `${STUDENT_PROFILE_URL}/profile/referrals`, - referredFirstName: referral.referredFirstName, - referredLastName: referral.referredLastName, - }, - name: 'referral-accepted', - to: referral.referrerEmail as string, - }); + job('student.linkedin.sync', { + memberIds: [studentId], + }); - job('gamification.activity.completed', { - referralId: application.referralId, - studentId: referral.referrerId as string, - type: 'refer_friend', - }); + if (application.referralId && !existingStudent) { + const referral = await db + .selectFrom('referrals') + .leftJoin( + 'students as referrers', + 'referrers.id', + 'referrals.referrerId' + ) + .select([ + 'referrals.firstName as referredFirstName', + 'referrals.lastName as referredLastName', + 'referrers.email as referrerEmail', + 'referrers.id as referrerId', + 'referrers.firstName as referrerFirstName', + ]) + .where('referrals.id', '=', application.referralId) + .executeTakeFirst(); + + if (referral) { + job('notification.email.send', { + data: { + firstName: referral.referrerFirstName as string, + referralsUri: `${STUDENT_PROFILE_URL}/profile/referrals`, + referredFirstName: referral.referredFirstName, + referredLastName: referral.referredLastName, + }, + name: 'referral-accepted', + to: referral.referrerEmail as string, + }); + + job('gamification.activity.completed', { + referralId: application.referralId, + studentId: referral.referrerId as string, + type: 'refer_friend', + }); + } } + } catch (error) { + console.error('Failed to accept application', error); + throw new Error('Failed to accept application'); } } @@ -330,7 +393,7 @@ export async function apply(input: ApplyInput) { const applicationId = id(); await db.transaction().execute(async (trx) => { - let referralId: string | undefined = undefined; + let referralId: string | undefined; if (input.referralId) { const referral = await db @@ -502,6 +565,26 @@ function queueRejectionEmail({ ); } +/** + * Finds an existing student with `bulk_removed` status that matches the given + * email or LinkedIn URL. This is used to reactivate students who were + * previously removed instead of creating new student records. + * + * @returns The student ID if found, otherwise null. + */ +async function findBulkRemovedStudent(email: string): Promise { + // Check by email first (more reliable identifier) + + const currentStudent = await db + .selectFrom('students') + .select(['id']) + .where('email', 'ilike', email) + .where('status', '=', MemberStatus.BULK_REMOVED) + .executeTakeFirst(); + + return currentStudent?.id ?? null; +} + // Worker export const applicationWorker = registerWorker( @@ -622,15 +705,48 @@ async function shouldReject( return [true, 'not_undergraduate']; } + // Email check - join with students to get status const memberWithSameEmail = await db .selectFrom('studentEmails') + .leftJoin('students', 'students.id', 'studentEmails.studentId') + .select(['studentEmails.email', 'students.status']) + .where('studentEmails.email', 'ilike', application.email) + .executeTakeFirst(); + + if ( + memberWithSameEmail && + memberWithSameEmail.status !== MemberStatus.BULK_REMOVED + ) { + return [true, 'email_already_used']; + } + + const bounced = await hasEmailBounced(application.email); + + if (bounced) { + return [true, 'email_bounced']; + } + + // Check for duplicate pending applications with the same email. + // If there's a newer pending application, reject this older one. + const newerPendingApplication = await db + .selectFrom('applications') .where('email', 'ilike', application.email) + .where('id', '!=', application.id) + .where('status', '=', ApplicationStatus.PENDING) + .where('createdAt', '>', application.createdAt) .executeTakeFirst(); - if (memberWithSameEmail) { + if (newerPendingApplication) { return [true, 'email_already_used']; } + if ( + memberWithSameEmail && + memberWithSameEmail.status === MemberStatus.BULK_REMOVED + ) { + return [false]; + } + const [memberWithSameLinkedIn, applicationAcceptedWithSameLinkedIn] = await Promise.all([ db @@ -658,13 +774,17 @@ async function shouldReject( .executeTakeFirst(); if (applicationAcceptedWithSameEmail) { - return [true, 'email_already_used']; - } + const student = await db + .selectFrom('students') + .select(['status']) + .where('email', 'ilike', application.email) + .executeTakeFirst(); - const bounced = await hasEmailBounced(application.email); + if (student?.status === MemberStatus.ACTIVE || !student) { + return [false]; + } - if (bounced) { - return [true, 'email_bounced']; + return [true, 'email_already_used']; } return [false]; diff --git a/packages/core/src/modules/authentication/use-cases/send-one-time-code.ts b/packages/core/src/modules/authentication/use-cases/send-one-time-code.ts index 6f1fd94d4..0e6e2e3d0 100644 --- a/packages/core/src/modules/authentication/use-cases/send-one-time-code.ts +++ b/packages/core/src/modules/authentication/use-cases/send-one-time-code.ts @@ -1,6 +1,7 @@ import { match } from 'ts-pattern'; import { db } from '@oyster/db'; +import { MemberStatus } from '@oyster/types'; import { id } from '@oyster/utils'; import { job } from '@/infrastructure/bull'; @@ -27,12 +28,20 @@ export async function sendOneTimeCode({ return db .selectFrom('studentEmails') .leftJoin('students', 'students.id', 'studentEmails.studentId') - .select(['students.id', 'students.firstName']) + .select(['students.id', 'students.firstName', 'students.status']) .where('studentEmails.email', 'ilike', email) .executeTakeFirst(); }) .exhaustive(); + const hasStudentStatus = entity && 'status' in entity; + + if (hasStudentStatus && entity.status !== MemberStatus.ACTIVE) { + throw new Error( + `This member has been deactivated from ColorStack. Please contact membership@colorstack.org for support.` + ); + } + if (!entity) { throw new Error( purpose === 'admin_login' diff --git a/packages/core/src/modules/members/events/member-status-update.ts b/packages/core/src/modules/members/events/member-status-update.ts new file mode 100644 index 000000000..2dc40765c --- /dev/null +++ b/packages/core/src/modules/members/events/member-status-update.ts @@ -0,0 +1,200 @@ +import { db } from '@oyster/db'; +import { MemberStatus } from '@oyster/types'; + +import { job } from '@/infrastructure/bull'; +import { type GetBullJobData } from '@/infrastructure/bull.types'; +import { + AIRTABLE_FAMILY_BASE_ID, + AIRTABLE_MEMBERS_TABLE_ID, +} from '@/modules/airtable'; + +export async function onMemberStatusUpdated({ + airtableId, + email, + firstName, + sendViolationEmail, + slackId, + studentId, + status, +}: GetBullJobData<'student.status_updated'>) { + if (status === MemberStatus.BULK_REMOVED) { + await onBulkRemoveStatusUpdate({ + airtableId, + email, + firstName, + sendViolationEmail, + slackId, + studentId, + }); + } + + if (status === MemberStatus.ACTIVE) { + await onActiveStatusUpdate({ + airtableId, + email, + firstName, + sendViolationEmail, + slackId, + studentId, + }); + } + + if (status === MemberStatus.INACTIVE) { + await onInactiveStatusUpdate({ + airtableId, + email, + firstName, + sendViolationEmail, + slackId, + studentId, + }); + } + + // TODO: Add other status updates here. + // if (status === MemberStatus.BANNED) { + // await onBannedStatusUpdate({ + // airtableId, + // email, + // firstName, + // sendViolationEmail, + // slackId, + // }); + // } +} + +type StatusUpdateProps = { + airtableId: string; + email: string; + firstName: string; + sendViolationEmail: boolean; + slackId?: string | null; + studentId: string; +}; + +async function onBulkRemoveStatusUpdate({ + airtableId, + email, + firstName, + sendViolationEmail, + slackId, +}: StatusUpdateProps) { + job('airtable.record.update', { + airtableBaseId: AIRTABLE_FAMILY_BASE_ID!, + airtableRecordId: airtableId, + airtableTableId: AIRTABLE_MEMBERS_TABLE_ID!, + data: { + status: MemberStatus.BULK_REMOVED, + }, + }); + + job('mailchimp.remove', { + email, + }); + + job('notification.slack.send', { + message: `Member with the email "${email}" has been marked as bulk removed from ColorStack.`, + workspace: 'internal', + }); + + if (slackId) { + job('slack.deactivate', { + slackId, + }); + } + + if (sendViolationEmail) { + job('notification.email.send', { + to: email, + name: 'student-removed', + data: { firstName }, + }); + } +} + +async function onActiveStatusUpdate({ + studentId, + slackId, + airtableId, +}: StatusUpdateProps) { + const student = await db + .selectFrom('students') + .select(['email', 'firstName', 'id', 'lastName']) + .where('id', '=', studentId) + .executeTakeFirstOrThrow(); + + job('airtable.record.update', { + airtableBaseId: AIRTABLE_FAMILY_BASE_ID!, + airtableRecordId: airtableId, + airtableTableId: AIRTABLE_MEMBERS_TABLE_ID!, + data: { + status: MemberStatus.ACTIVE, + }, + }); + + job('student.engagement.backfill', { + email: student.email, + studentId: student.id, + }); + + job('mailchimp.add', { + email: student.email, + firstName: student.firstName, + lastName: student.lastName, + }); + + if (slackId) { + job('slack.activate', { + slackId, + }); + } +} + +async function onInactiveStatusUpdate({ + airtableId, + email, + firstName, + sendViolationEmail, + slackId, +}: StatusUpdateProps) { + job('airtable.record.update', { + airtableBaseId: AIRTABLE_FAMILY_BASE_ID!, + airtableRecordId: airtableId, + airtableTableId: AIRTABLE_MEMBERS_TABLE_ID!, + data: { + status: MemberStatus.INACTIVE, + }, + }); + + job('mailchimp.remove', { + email, + }); + + job('notification.slack.send', { + message: `Member with the email "${email}" has been marked as inactive from ColorStack.`, + workspace: 'internal', + }); + + if (slackId) { + job('slack.deactivate', { + slackId, + }); + } + + if (sendViolationEmail) { + job('notification.email.send', { + to: email, + name: 'student-removed', + data: { firstName }, + }); + } +} + +// async function onBannedStatusUpdate({ +// airtableId, +// email, +// firstName, +// sendViolationEmail, +// slackId, +// }: StatusUpdateProps) { +// return; +// } diff --git a/packages/core/src/modules/members/members.worker.ts b/packages/core/src/modules/members/members.worker.ts index e8e501970..8cd2bb4bb 100644 --- a/packages/core/src/modules/members/members.worker.ts +++ b/packages/core/src/modules/members/members.worker.ts @@ -15,7 +15,9 @@ import { } from '@/modules/airtable'; import { sendCompanyReviewNotifications } from '@/modules/employment/use-cases/send-company-review-notifications'; import { syncLinkedInProfiles } from '@/modules/linkedin'; +import { onMemberStatusUpdated } from '@/modules/members/events/member-status-update'; import { batchRemoveMembers } from '@/modules/members/use-cases/batch-remove-members'; +import { batchUpdateMemberStatus } from '@/modules/members/use-cases/batch-update-members-status'; import { sendAnniversaryEmail } from '@/modules/members/use-cases/send-anniversary-email'; import { sendGraduationEmail } from '@/modules/members/use-cases/send-graduation-email'; import { success } from '@/shared/utils/core'; @@ -36,6 +38,9 @@ export const memberWorker = registerWorker( .with({ name: 'student.batch_remove' }, ({ data }) => { return batchRemoveMembers(data); }) + .with({ name: 'student.batch_update_status' }, ({ data }) => { + return batchUpdateMemberStatus(data); + }) .with({ name: 'student.birthdate.daily' }, ({ data }) => { return sendBirthdayNotification(data); }) @@ -63,6 +68,9 @@ export const memberWorker = registerWorker( .with({ name: 'student.removed' }, ({ data }) => { return onMemberRemoved(data); }) + .with({ name: 'student.status_updated' }, ({ data }) => { + return onMemberStatusUpdated(data); + }) .with({ name: 'student.statuses.backfill' }, ({ data }) => { return backfillActiveStatuses(data); }) diff --git a/packages/core/src/modules/members/queries/get-member-by-email.ts b/packages/core/src/modules/members/queries/get-member-by-email.ts index 4eb451952..b71dc96b5 100644 --- a/packages/core/src/modules/members/queries/get-member-by-email.ts +++ b/packages/core/src/modules/members/queries/get-member-by-email.ts @@ -1,4 +1,5 @@ import { db } from '@oyster/db'; +import { MemberStatus } from '@oyster/types'; export function getMemberByEmail(email: string) { return db @@ -6,5 +7,6 @@ export function getMemberByEmail(email: string) { .leftJoin('studentEmails', 'studentEmails.studentId', 'students.id') .select(['students.id']) .where('studentEmails.email', 'ilike', email) + .where('students.status', '=', MemberStatus.ACTIVE) .executeTakeFirst(); } diff --git a/packages/core/src/modules/members/queries/get-member-by-id.ts b/packages/core/src/modules/members/queries/get-member-by-id.ts new file mode 100644 index 000000000..4ac1e7fe2 --- /dev/null +++ b/packages/core/src/modules/members/queries/get-member-by-id.ts @@ -0,0 +1,9 @@ +import { db } from '@oyster/db'; + +export function getMemberById(id: string) { + return db + .selectFrom('students') + .select(['students.id', 'students.status']) + .where('students.id', '=', id) + .executeTakeFirst(); +} diff --git a/packages/core/src/modules/members/use-cases/batch-update-members-status.ts b/packages/core/src/modules/members/use-cases/batch-update-members-status.ts new file mode 100644 index 000000000..47356c1d0 --- /dev/null +++ b/packages/core/src/modules/members/use-cases/batch-update-members-status.ts @@ -0,0 +1,29 @@ +import { db } from '@oyster/db'; + +import { job } from '@/infrastructure/bull'; +import { type GetBullJobData } from '@/infrastructure/bull.types'; + +export async function batchUpdateMemberStatus({ + memberIds, + status, + sendViolationEmail, +}: GetBullJobData<'student.batch_update_status'>) { + const students = await db + .updateTable('students') + .set({ status }) + .where('id', 'in', memberIds) + .returning(['airtableId', 'email', 'firstName', 'id', 'slackId']) + .execute(); + + for (const student of students) { + job('student.status_updated', { + airtableId: student.airtableId as string, + email: student.email, + firstName: student.firstName, + sendViolationEmail: sendViolationEmail ?? false, + slackId: student.slackId, + status, + studentId: student.id, + }); + } +} diff --git a/packages/core/src/modules/onboarding-sessions/use-cases/upload-onboarding-session.ts b/packages/core/src/modules/onboarding-sessions/use-cases/upload-onboarding-session.ts index 62d63a1be..06301e561 100644 --- a/packages/core/src/modules/onboarding-sessions/use-cases/upload-onboarding-session.ts +++ b/packages/core/src/modules/onboarding-sessions/use-cases/upload-onboarding-session.ts @@ -1,6 +1,7 @@ import dayjs from 'dayjs'; import { db } from '@oyster/db'; +import { MemberStatus } from '@oyster/types'; import { id } from '@oyster/utils'; import { job } from '@/infrastructure/bull'; @@ -53,14 +54,31 @@ export async function uploadOnboardingSession( .execute(); }); - attendees.forEach((attendee) => { - job('onboarding_session.attended', { - onboardingSessionId, - studentId: attendee.id, - }); + await Promise.all( + attendees.map(async (attendee) => { + const currentStudent = await db + .selectFrom('students') + .select(['airtableId', 'email', 'firstName', 'slackId', 'status']) + .where('id', '=', attendee.id) + .executeTakeFirstOrThrow(); - if (!attendee.slackId) { - job('slack.invite', { email: attendee.email }); - } - }); + const isBulkRemoved = currentStudent.status === MemberStatus.BULK_REMOVED; + + if (isBulkRemoved) { + job('student.batch_update_status', { + memberIds: [attendee.id], + status: MemberStatus.ACTIVE, + }); + } else { + if (!attendee.slackId) { + job('slack.invite', { email: attendee.email }); + } + } + + job('onboarding_session.attended', { + onboardingSessionId, + studentId: attendee.id, + }); + }) + ); } diff --git a/packages/core/src/modules/slack/services/slack-admin.service.ts b/packages/core/src/modules/slack/services/slack-admin.service.ts index d3b90f1ed..091b69fa1 100644 --- a/packages/core/src/modules/slack/services/slack-admin.service.ts +++ b/packages/core/src/modules/slack/services/slack-admin.service.ts @@ -14,6 +14,14 @@ const SlackResponse = z.object({ class DeactivateSlackUserError extends ErrorWithContext { message = 'Failed to deactivate Slack user.'; } +class ActivateSlackUserError extends ErrorWithContext { + message = 'Failed to activate Slack user.'; +} + +const activateRateLimiter = new RateLimiter('slack:connections:activate', { + rateLimit: 20, + rateLimitWindow: 60, +}); const deactivateRateLimiter = new RateLimiter('slack:connections:deactivate', { rateLimit: 20, @@ -128,6 +136,42 @@ async function fetchFromSlack( return response; } +export async function activateSlackUser(id: string) { + await activateRateLimiter.process(); + + const response = await fetchFromSlack( + 'https://slack.com/api/users.admin.setRegular', + { + body: new URLSearchParams({ user: id }), + } + ); + + const data = await response.json(); + + const result = SlackResponse.safeParse(data); + + let error: Error | null = null; + + if (!result.success) { + error = new ZodParseError(result.error); + } else if (!result.data.ok) { + error = new ActivateSlackUserError().withContext({ + code: result.data.error, + }); + } + + if (error) { + reportException(error); + throw error; + } + + console.log({ + code: 'slack_user_activated', + message: 'Slack user was activated.', + data: { slackId: id }, + }); +} + // /** diff --git a/packages/core/src/modules/slack/slack.worker.ts b/packages/core/src/modules/slack/slack.worker.ts index 6aaf0a3ea..f50459450 100644 --- a/packages/core/src/modules/slack/slack.worker.ts +++ b/packages/core/src/modules/slack/slack.worker.ts @@ -10,6 +10,7 @@ import { answerPublicQuestionInPrivate, syncThreadToPinecone, } from '@/modules/slack/slack'; +import { activateSlackUser } from '@/modules/slack/use-cases/activate-slack-user'; import { updateBirthdatesFromSlack } from '@/modules/slack/use-cases/update-birthdates-from-slack'; import { onSlackProfilePictureChanged } from './events/slack-profile-picture-changed'; import { onSlackReactionAdded } from './events/slack-reaction-added'; @@ -57,6 +58,9 @@ export const slackWorker = registerWorker( .with({ name: 'slack.deactivate' }, async ({ data }) => { return deactivateSlackUser(data); }) + .with({ name: 'slack.activate' }, async ({ data }) => { + return activateSlackUser(data); + }) .with({ name: 'slack.emoji.changed' }, async ({ data }) => { if (data.subtype === 'add') { return onSlackEmojiAdded(data); diff --git a/packages/core/src/modules/slack/use-cases/activate-slack-user.ts b/packages/core/src/modules/slack/use-cases/activate-slack-user.ts new file mode 100644 index 000000000..714b5fca1 --- /dev/null +++ b/packages/core/src/modules/slack/use-cases/activate-slack-user.ts @@ -0,0 +1,8 @@ +import { type GetBullJobData } from '@/infrastructure/bull.types'; +import { activateSlackUser as _activateSlackUser } from '../services/slack-admin.service'; + +export async function activateSlackUser({ + slackId, +}: GetBullJobData<'slack.activate'>) { + await _activateSlackUser(slackId); +} diff --git a/packages/db/src/migrations/20251216143316_member_status.ts b/packages/db/src/migrations/20251216143316_member_status.ts new file mode 100644 index 000000000..ff4931f79 --- /dev/null +++ b/packages/db/src/migrations/20251216143316_member_status.ts @@ -0,0 +1,14 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely) { + await db.schema + .alterTable('students') + .addColumn('status', 'text', (column) => { + return column.notNull().defaultTo('active'); + }) + .execute(); +} + +export async function down(db: Kysely) { + await db.schema.alterTable('students').dropColumn('status').execute(); +} diff --git a/packages/types/src/domain/student.ts b/packages/types/src/domain/student.ts index 450027830..150ae964f 100644 --- a/packages/types/src/domain/student.ts +++ b/packages/types/src/domain/student.ts @@ -93,6 +93,13 @@ const StudentLocation = z.object({ hometownLongitude: z.coerce.number().nullable(), }); +export const MemberStatus = { + ACTIVE: 'active', + INACTIVE: 'inactive', + BULK_REMOVED: 'bulk_removed', + BANNED: 'banned', +} as const; + export const WorkAuthorizationStatus = { AUTHORIZED: 'authorized', NEEDS_SPONSORSHIP: 'needs_sponsorship', @@ -192,6 +199,17 @@ export const Student = Entity.merge(StudentSocialLinks) }), slackId: z.string().optional(), + + /** + * The overall membership status of the member in ColorStack. + * + * - `active`: Current active member + * - `inactive`: Member who left or became inactive + * - `bulk_removed`: Member removed via bulk removal tool + * - `banned`: Member permanently blocked from platform + */ + status: z.nativeEnum(MemberStatus), + type: z.nativeEnum(MemberType), /** @@ -211,7 +229,7 @@ export const StudentEmail = Entity.omit({ export const StudentActiveStatus = z.object({ date: z.string().trim().min(1), - status: z.enum(['active', 'inactive']), + status: z.nativeEnum(MemberStatus), studentId: Student.shape.id, }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3edaa65bd..a7467be06 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,6 +8,7 @@ export { export { ProfileView } from './domain/profile-view'; export { MemberEthnicity, + MemberStatus, MemberType, Student, StudentActiveStatus,