From f25e1ac59755f730bb8cb925893b0f238eb18a10 Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Wed, 17 Dec 2025 07:27:47 -0500 Subject: [PATCH 01/12] feat: add member status-> active,inactive,bulk_removed,banned --- .../20251216143316_member_status.ts | 14 +++++++++++++ packages/types/src/domain/student.ts | 20 ++++++++++++++++++- packages/types/src/index.ts | 1 + 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/migrations/20251216143316_member_status.ts 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, From 15b68ae4586f8831c0208556b4baf1b6a18e8797 Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Wed, 17 Dec 2025 12:02:15 -0500 Subject: [PATCH 02/12] feat: able to batch update member status --- .../app/routes/_dashboard.students.remove.tsx | 13 +- .../app/routes/_dashboard.students.tsx | 29 +++- .../core/src/infrastructure/bull.types.ts | 19 +++ .../members/events/member-status-update.ts | 136 ++++++++++++++++++ .../src/modules/members/members.worker.ts | 8 ++ .../use-cases/batch-update-members-status.ts | 27 ++++ 6 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/modules/members/events/member-status-update.ts create mode 100644 packages/core/src/modules/members/use-cases/batch-update-members-status.ts 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..84ea5decc 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.students.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.students.tsx @@ -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 || '-', }, { diff --git a/packages/core/src/infrastructure/bull.types.ts b/packages/core/src/infrastructure/bull.types.ts index 2b1eb8860..df5945667 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, @@ -626,6 +627,13 @@ 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), + }), + }), z.object({ name: z.literal('student.birthdate.daily'), data: z.object({}), @@ -675,6 +683,17 @@ 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), + }), + }), z.object({ name: z.literal('student.statuses.backfill'), data: z.object({ 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..7e40f7bb1 --- /dev/null +++ b/packages/core/src/modules/members/events/member-status-update.ts @@ -0,0 +1,136 @@ +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, + status, +}: GetBullJobData<'student.status_updated'>) { + if (status === MemberStatus.BULK_REMOVED) { + await onBulkRemoveStatusUpdate({ + airtableId, + email, + firstName, + sendViolationEmail, + slackId, + }); + } + + // TODO: Add other status updates here. + // if (status === MemberStatus.ACTIVE) { + // await onActiveStatusUpdate({ + // airtableId, + // email, + // firstName, + // sendViolationEmail, + // slackId, + // }); + // } + + // if (status === MemberStatus.INACTIVE) { + // await onInactiveStatusUpdate({ + // airtableId, + // email, + // firstName, + // sendViolationEmail, + // slackId, + // }); + // } + + // if (status === MemberStatus.BANNED) { + // await onBannedStatusUpdate({ + // airtableId, + // email, + // firstName, + // sendViolationEmail, + // slackId, + // }); + // } +} + +type StatusUpdateProps = { + airtableId: string; + email: string; + firstName: string; + sendViolationEmail: boolean; + slackId?: string | null; +}; + +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 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({ +// airtableId, +// email, +// firstName, +// sendViolationEmail, +// slackId, +// }: StatusUpdateProps) { +// return; +// } + +// async function onInactiveStatusUpdate({ +// airtableId, +// email, +// firstName, +// sendViolationEmail, +// slackId, +// }: StatusUpdateProps) { +// return; +// } + +// 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/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..c4676fd1a --- /dev/null +++ b/packages/core/src/modules/members/use-cases/batch-update-members-status.ts @@ -0,0 +1,27 @@ +import { db } from '@oyster/db'; + +import { job } from '@/infrastructure/bull'; +import { type GetBullJobData } from '@/infrastructure/bull.types'; + +export async function batchUpdateMemberStatus({ + memberIds, + status, +}: GetBullJobData<'student.batch_update_status'>) { + const students = await db + .updateTable('students') + .set({ status }) + .where('id', 'in', memberIds) + .returning(['airtableId', 'email', 'firstName', 'slackId']) + .execute(); + + for (const student of students) { + job('student.status_updated', { + airtableId: student.airtableId as string, + email: student.email, + firstName: student.firstName, + sendViolationEmail: false, + slackId: student.slackId, + status, + }); + } +} From b7abfc9985d9548b376ea7fae300f1f65e734df4 Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Thu, 18 Dec 2025 08:25:09 -0500 Subject: [PATCH 03/12] feat: prevent students from signing in if their status is not active --- apps/member-profile/app/shared/session.server.ts | 14 ++++++++++++++ packages/core/src/member-profile.server.ts | 1 + .../authentication/use-cases/send-one-time-code.ts | 12 +++++++++++- .../modules/members/queries/get-member-by-email.ts | 2 ++ .../modules/members/queries/get-member-by-id.ts | 9 +++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/modules/members/queries/get-member-by-id.ts 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/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/authentication/use-cases/send-one-time-code.ts b/packages/core/src/modules/authentication/use-cases/send-one-time-code.ts index 6f1fd94d4..cc8ce2bed 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,21 @@ 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(); + // .where('students.status', '=', MemberStatus.ACTIVE) }) .exhaustive(); + const hasStudentStatus = entity && 'status' in entity; + + if (hasStudentStatus && entity.status === MemberStatus.BULK_REMOVED) { + throw new Error( + `This member has been deactivated from ColorStack. Please contact support.` + ); + } + if (!entity) { throw new Error( purpose === 'admin_login' 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(); +} From 296dd69c42da5ab233b2f34ea9ae0de79e90b5ab Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Thu, 18 Dec 2025 08:26:30 -0500 Subject: [PATCH 04/12] chore --- .../src/modules/authentication/use-cases/send-one-time-code.ts | 1 - 1 file changed, 1 deletion(-) 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 cc8ce2bed..fdebba541 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 @@ -31,7 +31,6 @@ export async function sendOneTimeCode({ .select(['students.id', 'students.firstName', 'students.status']) .where('studentEmails.email', 'ilike', email) .executeTakeFirst(); - // .where('students.status', '=', MemberStatus.ACTIVE) }) .exhaustive(); From 2921d3ffad4b93a0dad9d33273c49229025bd26a Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Thu, 18 Dec 2025 14:35:20 -0500 Subject: [PATCH 05/12] feat: update application to prevent auto reject for students that have been bulk removed --- .../app/routes/_dashboard.applications.tsx | 1 + .../core/src/infrastructure/bull.types.ts | 1 + .../src/modules/applications/applications.ts | 201 ++++++++++++++---- .../members/events/member-status-update.ts | 55 +++-- .../use-cases/batch-update-members-status.ts | 3 +- 5 files changed, 194 insertions(+), 67 deletions(-) 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/packages/core/src/infrastructure/bull.types.ts b/packages/core/src/infrastructure/bull.types.ts index df5945667..e6f24c7fb 100644 --- a/packages/core/src/infrastructure/bull.types.ts +++ b/packages/core/src/infrastructure/bull.types.ts @@ -692,6 +692,7 @@ export const StudentBullJob = z.discriminatedUnion('name', [ sendViolationEmail: z.boolean(), slackId: Student.shape.slackId.nullable(), status: z.nativeEnum(MemberStatus), + studentId: Student.shape.id, }), }), z.object({ diff --git a/packages/core/src/modules/applications/applications.ts b/packages/core/src/modules/applications/applications.ts index 67c1a4dd9..4dd07a093 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'; @@ -195,6 +199,7 @@ export async function acceptApplication( .executeTakeFirstOrThrow(); let studentId = ''; + let existingStudent = false; await db.transaction().execute(async (trx) => { await trx @@ -227,11 +232,6 @@ export async function acceptApplication( .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( @@ -240,43 +240,112 @@ export async function acceptApplication( } ); - studentId = id(); + // Check if there's an existing bulk_removed student to reactivate + const existingStudentId = await findBulkRemovedStudent(application.email); - 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(); + 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(); - await trx - .updateTable('studentEmails') - .set({ studentId }) - .where('email', '=', application.email) - .execute(); - }); + studentId = existingStudentId; - job('student.created', { - studentId, + // 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(); + + await trx + .updateTable('studentEmails') + .set({ studentId }) + .where('email', '=', application.email) + .execute(); + } }); + if (!existingStudent) { + job('student.created', { + studentId, + }); + // const currentStudent = await db + // .selectFrom('students') + // .select(['airtableId', 'email', 'firstName', 'slackId']) + // .where('id', '=', studentId) + // .executeTakeFirstOrThrow(); + + // job('student.status_updated', { + // airtableId: currentStudent.airtableId as string, + // email: currentStudent.email, + // firstName: currentStudent.firstName, + // sendViolationEmail: false, + // slackId: currentStudent.slackId, + // status: MemberStatus.ACTIVE, + // studentId, + // }); + } + job('notification.email.send', { data: { firstName: application.firstName }, name: 'application-accepted', @@ -287,7 +356,7 @@ export async function acceptApplication( memberIds: [studentId], }); - if (application.referralId) { + if (application.referralId && !existingStudent) { const referral = await db .selectFrom('referrals') .leftJoin('students as referrers', 'referrers.id', 'referrals.referrerId') @@ -502,6 +571,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) + .executeTakeFirstOrThrow(); + + return currentStudent?.id ?? null; +} + // Worker export const applicationWorker = registerWorker( @@ -551,6 +640,11 @@ async function reviewApplication({ return; } + console.log('this is being called', { + reject, + reason, + }); + await db.transaction().execute(async (trx) => { await trx .updateTable('applications') @@ -622,15 +716,34 @@ async function shouldReject( return [true, 'not_undergraduate']; } + // Email check - join with students to get status const memberWithSameEmail = await db .selectFrom('studentEmails') - .where('email', 'ilike', application.email) + .leftJoin('students', 'students.id', 'studentEmails.studentId') + .select(['studentEmails.email', 'students.status']) + .where('studentEmails.email', 'ilike', application.email) .executeTakeFirst(); - if (memberWithSameEmail) { + if ( + memberWithSameEmail && + memberWithSameEmail.status !== MemberStatus.BULK_REMOVED + ) { return [true, 'email_already_used']; } + const bounced = await hasEmailBounced(application.email); + + if (bounced) { + return [true, 'email_bounced']; + } + + if ( + memberWithSameEmail && + memberWithSameEmail.status === MemberStatus.BULK_REMOVED + ) { + return [false]; + } + const [memberWithSameLinkedIn, applicationAcceptedWithSameLinkedIn] = await Promise.all([ db @@ -661,11 +774,5 @@ async function shouldReject( return [true, 'email_already_used']; } - const bounced = await hasEmailBounced(application.email); - - if (bounced) { - return [true, 'email_bounced']; - } - return [false]; } diff --git a/packages/core/src/modules/members/events/member-status-update.ts b/packages/core/src/modules/members/events/member-status-update.ts index 7e40f7bb1..408e31867 100644 --- a/packages/core/src/modules/members/events/member-status-update.ts +++ b/packages/core/src/modules/members/events/member-status-update.ts @@ -1,3 +1,4 @@ +import { db } from '@oyster/db'; import { MemberStatus } from '@oyster/types'; import { job } from '@/infrastructure/bull'; @@ -13,6 +14,7 @@ export async function onMemberStatusUpdated({ firstName, sendViolationEmail, slackId, + studentId, status, }: GetBullJobData<'student.status_updated'>) { if (status === MemberStatus.BULK_REMOVED) { @@ -22,20 +24,22 @@ export async function onMemberStatusUpdated({ firstName, sendViolationEmail, slackId, + studentId, }); } - // TODO: Add other status updates here. - // if (status === MemberStatus.ACTIVE) { - // await onActiveStatusUpdate({ - // airtableId, - // email, - // firstName, - // sendViolationEmail, - // slackId, - // }); - // } + if (status === MemberStatus.ACTIVE) { + await onActiveStatusUpdate({ + airtableId, + email, + firstName, + sendViolationEmail, + slackId, + studentId, + }); + } + // TODO: Add other status updates here. // if (status === MemberStatus.INACTIVE) { // await onInactiveStatusUpdate({ // airtableId, @@ -63,6 +67,7 @@ type StatusUpdateProps = { firstName: string; sendViolationEmail: boolean; slackId?: string | null; + studentId: string; }; async function onBulkRemoveStatusUpdate({ @@ -71,6 +76,7 @@ async function onBulkRemoveStatusUpdate({ firstName, sendViolationEmail, slackId, + studentId, }: StatusUpdateProps) { job('airtable.record.update', { airtableBaseId: AIRTABLE_FAMILY_BASE_ID!, @@ -105,15 +111,26 @@ async function onBulkRemoveStatusUpdate({ } } -// async function onActiveStatusUpdate({ -// airtableId, -// email, -// firstName, -// sendViolationEmail, -// slackId, -// }: StatusUpdateProps) { -// return; -// } +async function onActiveStatusUpdate({ studentId }: StatusUpdateProps) { + const student = await db + .selectFrom('students') + .select(['email', 'firstName', 'id', 'lastName']) + .where('id', '=', studentId) + .executeTakeFirstOrThrow(); + + job('student.engagement.backfill', { + email: student.email, + studentId: student.id, + }); + + job('mailchimp.add', { + email: student.email, + firstName: student.firstName, + lastName: student.lastName, + }); + + // TODO: reactivate slack user +} // async function onInactiveStatusUpdate({ // airtableId, 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 index c4676fd1a..244800b92 100644 --- 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 @@ -11,7 +11,7 @@ export async function batchUpdateMemberStatus({ .updateTable('students') .set({ status }) .where('id', 'in', memberIds) - .returning(['airtableId', 'email', 'firstName', 'slackId']) + .returning(['airtableId', 'email', 'firstName', 'id', 'slackId']) .execute(); for (const student of students) { @@ -22,6 +22,7 @@ export async function batchUpdateMemberStatus({ sendViolationEmail: false, slackId: student.slackId, status, + studentId: student.id, }); } } From a7b72111686805088f4118b70a6ca03179b0364e Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Thu, 18 Dec 2025 15:35:20 -0500 Subject: [PATCH 06/12] feat: update user status when onboarding is completed and reactivate, slack and mailchimp --- .../core/src/infrastructure/bull.types.ts | 6 +++ .../members/events/member-status-update.ts | 8 +++- .../use-cases/upload-onboarding-session.ts | 36 +++++++++++---- .../slack/services/slack-admin.service.ts | 44 +++++++++++++++++++ .../core/src/modules/slack/slack.worker.ts | 4 ++ .../slack/use-cases/activate-slack-user.ts | 8 ++++ 6 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/modules/slack/use-cases/activate-slack-user.ts diff --git a/packages/core/src/infrastructure/bull.types.ts b/packages/core/src/infrastructure/bull.types.ts index e6f24c7fb..d3e748d7e 100644 --- a/packages/core/src/infrastructure/bull.types.ts +++ b/packages/core/src/infrastructure/bull.types.ts @@ -473,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', [ diff --git a/packages/core/src/modules/members/events/member-status-update.ts b/packages/core/src/modules/members/events/member-status-update.ts index 408e31867..f4fa43cce 100644 --- a/packages/core/src/modules/members/events/member-status-update.ts +++ b/packages/core/src/modules/members/events/member-status-update.ts @@ -111,7 +111,7 @@ async function onBulkRemoveStatusUpdate({ } } -async function onActiveStatusUpdate({ studentId }: StatusUpdateProps) { +async function onActiveStatusUpdate({ studentId, slackId }: StatusUpdateProps) { const student = await db .selectFrom('students') .select(['email', 'firstName', 'id', 'lastName']) @@ -129,7 +129,11 @@ async function onActiveStatusUpdate({ studentId }: StatusUpdateProps) { lastName: student.lastName, }); - // TODO: reactivate slack user + if (slackId) { + job('slack.activate', { + slackId, + }); + } } // async function onInactiveStatusUpdate({ 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); +} From 2708a45b3d7cff50bd3fe0bd143988a0498f078c Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Fri, 19 Dec 2025 07:05:31 -0500 Subject: [PATCH 07/12] feat: update student remove modal to update status --- .../routes/_dashboard.students.$id.remove.tsx | 115 -------------- .../_dashboard.students.$id.status-update.tsx | 143 ++++++++++++++++++ .../app/routes/_dashboard.students.tsx | 6 +- apps/admin-dashboard/app/shared/constants.ts | 2 +- .../core/src/infrastructure/bull.types.ts | 1 + .../use-cases/send-one-time-code.ts | 2 +- .../members/events/member-status-update.ts | 70 ++++++--- .../use-cases/batch-update-members-status.ts | 3 +- 8 files changed, 201 insertions(+), 141 deletions(-) delete mode 100644 apps/admin-dashboard/app/routes/_dashboard.students.$id.remove.tsx create mode 100644 apps/admin-dashboard/app/routes/_dashboard.students.$id.status-update.tsx 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..4768a8774 --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.students.$id.status-update.tsx @@ -0,0 +1,143 @@ +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.tsx b/apps/admin-dashboard/app/routes/_dashboard.students.tsx index 84ea5decc..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 { @@ -296,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/packages/core/src/infrastructure/bull.types.ts b/packages/core/src/infrastructure/bull.types.ts index d3e748d7e..3932c03da 100644 --- a/packages/core/src/infrastructure/bull.types.ts +++ b/packages/core/src/infrastructure/bull.types.ts @@ -638,6 +638,7 @@ export const StudentBullJob = z.discriminatedUnion('name', [ data: z.object({ memberIds: z.array(Student.shape.id), status: z.nativeEnum(MemberStatus), + sendViolationEmail: z.boolean().optional(), }), }), z.object({ 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 fdebba541..829441807 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 @@ -36,7 +36,7 @@ export async function sendOneTimeCode({ const hasStudentStatus = entity && 'status' in entity; - if (hasStudentStatus && entity.status === MemberStatus.BULK_REMOVED) { + if (hasStudentStatus && entity.status !== MemberStatus.ACTIVE) { throw new Error( `This member has been deactivated from ColorStack. Please contact support.` ); diff --git a/packages/core/src/modules/members/events/member-status-update.ts b/packages/core/src/modules/members/events/member-status-update.ts index f4fa43cce..b40099cad 100644 --- a/packages/core/src/modules/members/events/member-status-update.ts +++ b/packages/core/src/modules/members/events/member-status-update.ts @@ -39,17 +39,18 @@ export async function onMemberStatusUpdated({ }); } - // TODO: Add other status updates here. - // if (status === MemberStatus.INACTIVE) { - // await onInactiveStatusUpdate({ - // airtableId, - // email, - // firstName, - // sendViolationEmail, - // slackId, - // }); - // } + 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, @@ -76,7 +77,6 @@ async function onBulkRemoveStatusUpdate({ firstName, sendViolationEmail, slackId, - studentId, }: StatusUpdateProps) { job('airtable.record.update', { airtableBaseId: AIRTABLE_FAMILY_BASE_ID!, @@ -136,15 +136,45 @@ async function onActiveStatusUpdate({ studentId, slackId }: StatusUpdateProps) { } } -// async function onInactiveStatusUpdate({ -// airtableId, -// email, -// firstName, -// sendViolationEmail, -// slackId, -// }: StatusUpdateProps) { -// return; -// } +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 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 onBannedStatusUpdate({ // airtableId, 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 index 244800b92..47356c1d0 100644 --- 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 @@ -6,6 +6,7 @@ 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') @@ -19,7 +20,7 @@ export async function batchUpdateMemberStatus({ airtableId: student.airtableId as string, email: student.email, firstName: student.firstName, - sendViolationEmail: false, + sendViolationEmail: sendViolationEmail ?? false, slackId: student.slackId, status, studentId: student.id, From 26cd000b95a899536c94f69176cba0113d86f484 Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Fri, 19 Dec 2025 07:21:35 -0500 Subject: [PATCH 08/12] fix: ensure member airtable status gets updated --- .../_dashboard.students.$id.status-update.tsx | 8 ++++++-- .../members/events/member-status-update.ts | 19 ++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) 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 index 4768a8774..cd48aec83 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.students.$id.status-update.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.students.$id.status-update.tsx @@ -112,8 +112,12 @@ export default function UpdateStatusPage() { value={selectedStatus} onChange={(e) => setSelectedStatus(e.currentTarget.value)} > - - + {student.status !== MemberStatus.ACTIVE && ( + + )} + {student.status !== MemberStatus.INACTIVE && ( + + )} diff --git a/packages/core/src/modules/members/events/member-status-update.ts b/packages/core/src/modules/members/events/member-status-update.ts index b40099cad..2dc40765c 100644 --- a/packages/core/src/modules/members/events/member-status-update.ts +++ b/packages/core/src/modules/members/events/member-status-update.ts @@ -92,7 +92,7 @@ async function onBulkRemoveStatusUpdate({ }); job('notification.slack.send', { - message: `Member with the email "${email}" has been removed from ColorStack.`, + message: `Member with the email "${email}" has been marked as bulk removed from ColorStack.`, workspace: 'internal', }); @@ -111,13 +111,26 @@ async function onBulkRemoveStatusUpdate({ } } -async function onActiveStatusUpdate({ studentId, slackId }: StatusUpdateProps) { +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, @@ -157,7 +170,7 @@ async function onInactiveStatusUpdate({ }); job('notification.slack.send', { - message: `Member with the email "${email}" has been removed from ColorStack.`, + message: `Member with the email "${email}" has been marked as inactive from ColorStack.`, workspace: 'internal', }); From cdcbbd4a5410938a43a49bd22c44372e3c5b5fc8 Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Fri, 19 Dec 2025 07:55:17 -0500 Subject: [PATCH 09/12] fix: prevent accepting applications --- .../src/modules/applications/applications.ts | 389 +++++++++--------- 1 file changed, 194 insertions(+), 195 deletions(-) diff --git a/packages/core/src/modules/applications/applications.ts b/packages/core/src/modules/applications/applications.ts index 4dd07a093..17f465dc1 100644 --- a/packages/core/src/modules/applications/applications.ts +++ b/packages/core/src/modules/applications/applications.ts @@ -174,220 +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 = ''; - let existingStudent = false; - - 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(); - - if (application.referralId) { - await trx - .updateTable('referrals') - .set({ status: ReferralStatus.ACCEPTED }) - .where('id', '=', application.referralId) - .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(); - - const allOtherDemographics = Object.values(OtherDemographic) as string[]; - - const otherDemographics = application.otherDemographics.filter( - (demographic) => { - return !allOtherDemographics.includes(demographic); - } - ); + .executeTakeFirstOrThrow(); - // Check if there's an existing bulk_removed student to reactivate - const existingStudentId = await findBulkRemovedStudent(application.email); + let studentId = ''; + let existingStudent = false; - if (existingStudentId) { - existingStudent = true; - // Reactivate existing student with updated profile data + await db.transaction().execute(async (trx) => { await trx - .updateTable('students') + .updateTable('applications') .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, + reviewedById: adminId, + status: ApplicationStatus.ACCEPTED, }) - .where('id', '=', existingStudentId) + .where('id', '=', applicationId) .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) { + if (application.referralId) { await trx - .insertInto('studentEmails') - .values({ email: application.email, studentId }) + .updateTable('referrals') + .set({ status: ReferralStatus.ACCEPTED }) + .where('id', '=', application.referralId) .execute(); } - } else { - // Create new student + + // 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 - .insertInto('studentEmails') - .values({ email: application.email }) + .deleteFrom('applications') + .where('email', '=', application.email) + .where('id', '!=', application.id) + .where('status', '=', ApplicationStatus.PENDING) .execute(); - studentId = id(); + const allOtherDemographics = Object.values(OtherDemographic) as string[]; - 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 otherDemographics = application.otherDemographics.filter( + (demographic) => { + return !allOtherDemographics.includes(demographic); + } + ); - await trx - .updateTable('studentEmails') - .set({ studentId }) - .where('email', '=', application.email) - .execute(); - } - }); + // Check if there's an existing bulk_removed student to reactivate + const existingStudentId = await findBulkRemovedStudent(application.email); - if (!existingStudent) { - job('student.created', { - studentId, - }); - // const currentStudent = await db - // .selectFrom('students') - // .select(['airtableId', 'email', 'firstName', 'slackId']) - // .where('id', '=', studentId) - // .executeTakeFirstOrThrow(); - - // job('student.status_updated', { - // airtableId: currentStudent.airtableId as string, - // email: currentStudent.email, - // firstName: currentStudent.firstName, - // sendViolationEmail: false, - // slackId: currentStudent.slackId, - // status: MemberStatus.ACTIVE, - // studentId, - // }); - } + 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(); - job('notification.email.send', { - data: { firstName: application.firstName }, - name: 'application-accepted', - to: application.email, - }); + studentId = existingStudentId; - job('student.linkedin.sync', { - memberIds: [studentId], - }); + // Add new email if it doesn't already exist + const existingEmail = await trx + .selectFrom('studentEmails') + .where('email', 'ilike', application.email) + .executeTakeFirst(); - 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 (!existingEmail) { + await trx + .insertInto('studentEmails') + .values({ email: application.email, studentId }) + .execute(); + } + } else { + // Create new student + await trx + .insertInto('studentEmails') + .values({ email: application.email }) + .execute(); - 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, - }); + 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(); + + await trx + .updateTable('studentEmails') + .set({ studentId }) + .where('email', '=', application.email) + .execute(); + } + }); - job('gamification.activity.completed', { - referralId: application.referralId, - studentId: referral.referrerId as string, - type: 'refer_friend', + if (!existingStudent) { + job('student.created', { + studentId, }); } + + job('notification.email.send', { + data: { firstName: application.firstName }, + name: 'application-accepted', + to: application.email, + }); + + job('student.linkedin.sync', { + memberIds: [studentId], + }); + + 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'); } } @@ -586,7 +580,7 @@ async function findBulkRemovedStudent(email: string): Promise { .select(['id']) .where('email', 'ilike', email) .where('status', '=', MemberStatus.BULK_REMOVED) - .executeTakeFirstOrThrow(); + .executeTakeFirst(); return currentStudent?.id ?? null; } @@ -640,11 +634,6 @@ async function reviewApplication({ return; } - console.log('this is being called', { - reject, - reason, - }); - await db.transaction().execute(async (trx) => { await trx .updateTable('applications') @@ -771,6 +760,16 @@ async function shouldReject( .executeTakeFirst(); if (applicationAcceptedWithSameEmail) { + const student = await db + .selectFrom('students') + .select(['status']) + .where('email', 'ilike', application.email) + .executeTakeFirst(); + + if (student?.status === MemberStatus.ACTIVE || !student) { + return [false]; + } + return [true, 'email_already_used']; } From 0c88cd11187dc4d07d75ef392febb3e5e6aac523 Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Thu, 8 Jan 2026 09:16:01 -0500 Subject: [PATCH 10/12] fix: remove duplicate pending application --- .../src/modules/applications/applications.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/modules/applications/applications.ts b/packages/core/src/modules/applications/applications.ts index 17f465dc1..0df49ae61 100644 --- a/packages/core/src/modules/applications/applications.ts +++ b/packages/core/src/modules/applications/applications.ts @@ -393,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 @@ -726,6 +726,20 @@ async function shouldReject( 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 (newerPendingApplication) { + return [true, 'email_already_used']; + } + if ( memberWithSameEmail && memberWithSameEmail.status === MemberStatus.BULK_REMOVED From a165fe0ca2e81a1c36187fd586fc424df2c39f22 Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Thu, 8 Jan 2026 09:19:49 -0500 Subject: [PATCH 11/12] fix: update deactivated member error message --- .../src/modules/authentication/use-cases/send-one-time-code.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 829441807..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 @@ -38,7 +38,7 @@ export async function sendOneTimeCode({ if (hasStudentStatus && entity.status !== MemberStatus.ACTIVE) { throw new Error( - `This member has been deactivated from ColorStack. Please contact support.` + `This member has been deactivated from ColorStack. Please contact membership@colorstack.org for support.` ); } From f7fab6a2c01d59be0a6fde40ad0fdd7530f4c031 Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Fri, 9 Jan 2026 15:52:02 -0500 Subject: [PATCH 12/12] fix: add missing status --- apps/member-profile/app/routes/_profile.home.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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