From 5e87f184b6447a8888f1788b3eb59cac813e04b1 Mon Sep 17 00:00:00 2001 From: Rami Abdou Date: Tue, 23 Sep 2025 09:29:38 -0700 Subject: [PATCH 1/6] allow max rows for textarea --- packages/ui/src/components/textarea.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/textarea.tsx b/packages/ui/src/components/textarea.tsx index 0cf135e88..16d6207b2 100644 --- a/packages/ui/src/components/textarea.tsx +++ b/packages/ui/src/components/textarea.tsx @@ -13,6 +13,7 @@ type TextareaProps = Pick< | 'defaultValue' | 'id' | 'maxLength' + | 'maxRows' | 'minLength' | 'minRows' | 'name' From 7abe2fecd118b37f282a39857c40f873f13fa4bc Mon Sep 17 00:00:00 2001 From: Rami Abdou Date: Tue, 23 Sep 2025 09:30:31 -0700 Subject: [PATCH 2/6] remove students --- .../app/routes/_dashboard.students.remove.tsx | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 apps/admin-dashboard/app/routes/_dashboard.students.remove.tsx diff --git a/apps/admin-dashboard/app/routes/_dashboard.students.remove.tsx b/apps/admin-dashboard/app/routes/_dashboard.students.remove.tsx new file mode 100644 index 000000000..e89eede82 --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.students.remove.tsx @@ -0,0 +1,140 @@ +import { + type ActionFunctionArgs, + data, + Form, + type LoaderFunctionArgs, + redirect, + useActionData, +} from 'react-router'; +import z from 'zod'; + +import { job } from '@oyster/core/bull'; +import { db } from '@oyster/db'; +import { + Button, + ErrorMessage, + Field, + getErrors, + Modal, + Textarea, + validateForm, +} from '@oyster/ui'; +import { Callout } from '@oyster/ui/callout'; + +import { Route } from '@/shared/constants'; +import { + commitSession, + ensureUserAuthenticated, + toast, +} from '@/shared/session.server'; + +export async function loader({ request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + return {}; +} + +const RemoveMembersFormData = z.object({ + memberIds: z + .string() + .min(1) + .transform((value) => value.split('\n').filter(Boolean)), +}); + +export async function action({ request }: ActionFunctionArgs) { + const session = await ensureUserAuthenticated(request); + + const form = await request.formData(); + + const result = await validateForm(form, RemoveMembersFormData); + + if (!result.ok) { + return data(result, { status: 400 }); + } + + const count = await removeMembers(result.data.memberIds); + + toast(session, { + message: `Removed ${count} members.`, + }); + + return redirect(Route['/students'], { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); +} + +async function removeMembers(ids: string[]): Promise { + const students = await db + .deleteFrom('students') + .where('id', 'in', ids) + .returning(['airtableId', 'email', 'firstName', 'slackId']) + .execute(); + + for (const student of students) { + job('student.removed', { + airtableId: student.airtableId as string, + email: student.email, + firstName: student.firstName, + sendViolationEmail: false, + slackId: student.slackId, + }); + } + + return students.length; +} + +export default function RemoveMembersPage() { + const { error, errors } = getErrors(useActionData()); + + return ( + + + Bulk Remove Members + + + + + This action is not reversible. All of their engagement records will be + deleted and they will be removed from Slack, Mailchimp and Airtable. + + + + Note: These members will immediately be removed from our database, but + it may take some time for them to be removed from Slack, Mailchimp and + Airtable. + + +
+ {error} + + +