diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index d24fdfc6..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -npx lint-staged diff --git a/CONTRIBUTORS.yml b/CONTRIBUTORS.yml index 5e7de010..855cceb4 100644 --- a/CONTRIBUTORS.yml +++ b/CONTRIBUTORS.yml @@ -44,3 +44,8 @@ - Habeebah157 - bryanansong - nathanallen242 +- Soogz +- poughe +- katlj +- rafa1510 +- Meron-b diff --git a/apps/admin-dashboard/app/routes/_dashboard.admins.$id.remove.tsx b/apps/admin-dashboard/app/routes/_dashboard.admins.$id.remove.tsx new file mode 100644 index 00000000..713eb209 --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.admins.$id.remove.tsx @@ -0,0 +1,94 @@ +import { + type ActionFunctionArgs, + json, + type LoaderFunctionArgs, + redirect, +} from '@remix-run/node'; +import { + Form as RemixForm, + useActionData, + useLoaderData, +} from '@remix-run/react'; + +import { getAdmin, removeAdmin } from '@oyster/core/admins'; +import { Button, Form, Modal } from '@oyster/ui'; + +import { Route } from '@/shared/constants'; +import { + commitSession, + ensureUserAuthenticated, + toast, + user, +} from '@/shared/session.server'; + +export async function loader({ params, request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + const admin = await getAdmin({ + select: ['admins.firstName', 'admins.lastName'], + where: { id: params.id as string }, + }); + + if (!admin) { + throw new Response(null, { status: 404 }); + } + + return json({ + admin, + }); +} + +export async function action({ params, request }: ActionFunctionArgs) { + const session = await ensureUserAuthenticated(request); + + const result = await removeAdmin({ + actor: user(session), + id: params.id as string, + }); + + if (!result.ok) { + return json({ error: result.error }, { status: result.code }); + } + + toast(session, { + message: 'Removed admin.', + }); + + return redirect(Route['/admins'], { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); +} + +export default function RemoveAdminPage() { + const { admin } = useLoaderData(); + const actionData = useActionData(); + + return ( + + + + Remove {admin.firstName} {admin.lastName} + + + + + + This is not an undoable action. Are you sure want to remove this admin? + + + + {actionData?.error} + + + Remove + + + + ); +} + +export function ErrorBoundary() { + return <>; +} diff --git a/apps/admin-dashboard/app/routes/_dashboard.admins.tsx b/apps/admin-dashboard/app/routes/_dashboard.admins.tsx index 08cf1f63..53dfb856 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.admins.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.admins.tsx @@ -1,23 +1,58 @@ import { json, type LoaderFunctionArgs } from '@remix-run/node'; import { Outlet, useLoaderData } from '@remix-run/react'; -import { listAdmins } from '@oyster/core/admins'; +import { + doesAdminHavePermission, + getAdmin, + listAdmins, +} from '@oyster/core/admins'; +import { type AdminRole } from '@oyster/core/admins.types'; import { AdminTable } from '@oyster/core/admins.ui'; import { Dashboard } from '@oyster/ui'; import { ensureUserAuthenticated } from '@/shared/session.server'; +import { user } from '@/shared/session.server'; export async function loader({ request }: LoaderFunctionArgs) { - await ensureUserAuthenticated(request); - - const admins = await listAdmins({ - select: [ - 'admins.firstName', - 'admins.lastName', - 'admins.email', - 'admins.id', - 'admins.role', - ], + const session = await ensureUserAuthenticated(request); + + const [admin, _admins] = await Promise.all([ + getAdmin({ + select: ['admins.id', 'admins.role'], + where: { id: user(session) }, + }), + listAdmins({ + select: [ + 'admins.deletedAt', + 'admins.firstName', + 'admins.lastName', + 'admins.email', + 'admins.id', + 'admins.role', + ], + }), + ]); + + if (!admin) { + throw new Response(null, { status: 404 }); + } + + const admins = _admins.map(({ deletedAt, ...row }) => { + return { + ...row, + + // Admins can't delete themselves nor can they delete other admins with + // a higher role. + canRemove: + !deletedAt && + row.id !== admin.id && + doesAdminHavePermission({ + minimumRole: row.role as AdminRole, + role: admin.role as AdminRole, + }), + + isDeleted: !!deletedAt, + }; }); return json({ diff --git a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue._index.tsx b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue._index.tsx deleted file mode 100644 index fefc44ce..00000000 --- a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue._index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { type LoaderFunctionArgs, redirect } from '@remix-run/node'; -import { generatePath } from '@remix-run/react'; - -import { Route } from '@/shared/constants'; -import { ensureUserAuthenticated } from '@/shared/session.server'; - -export async function loader({ params, request }: LoaderFunctionArgs) { - await ensureUserAuthenticated(request, { - minimumRole: 'owner', - }); - - return redirect( - generatePath(Route['/bull/:queue/jobs'], { - queue: params.queue as string, - }) - ); -} diff --git a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.$id.tsx b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.$id.tsx index 51dbab7e..05ebff8e 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.$id.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.$id.tsx @@ -1,33 +1,20 @@ -import { - type ActionFunctionArgs, - json, - type LoaderFunctionArgs, -} from '@remix-run/node'; +import { json, type LoaderFunctionArgs } from '@remix-run/node'; import { generatePath, - Form as RemixForm, + type Params, useLoaderData, useParams, } from '@remix-run/react'; import dayjs from 'dayjs'; import { type PropsWithChildren } from 'react'; -import { ArrowUp, Copy, RefreshCw, Trash } from 'react-feather'; -import { match } from 'ts-pattern'; -import { z } from 'zod'; -import { IconButton, Modal, Text } from '@oyster/ui'; +import { Modal, Text } from '@oyster/ui'; -import { QueueFromName } from '@/admin-dashboard.server'; -import { BullQueue } from '@/admin-dashboard.ui'; +import { validateQueue } from '@/shared/bull'; import { Route } from '@/shared/constants'; import { getTimezone } from '@/shared/cookies.server'; import { ensureUserAuthenticated } from '@/shared/session.server'; -const BullParams = z.object({ - queue: z.nativeEnum(BullQueue), - id: z.string(), -}); - export async function loader({ params, request }: LoaderFunctionArgs) { await ensureUserAuthenticated(request, { minimumRole: 'owner', @@ -84,60 +71,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { }); } -const JobAction = { - DUPLICATE: 'duplicate', - PROMOTE: 'promote', - REMOVE: 'remove', - RETRY: 'retry', -} as const; - -export async function action({ params, request }: ActionFunctionArgs) { - await ensureUserAuthenticated(request, { - minimumRole: 'owner', - }); - - const form = await request.formData(); - - const result = z - .nativeEnum(JobAction) - .safeParse(Object.fromEntries(form).action); - - if (!result.success) { - throw new Response(null, { status: 400 }); - } - - const job = await getJobFromParams(params); - - await match(result.data) - .with('duplicate', async () => { - const queue = QueueFromName[job.queueName as BullQueue]; - - return queue.add(job.name, job.data); - }) - .with('promote', async () => { - return job.promote(); - }) - .with('remove', async () => { - return job.remove(); - }) - .with('retry', async () => { - return job.retry(); - }) - .exhaustive(); - - return json({}); -} - -async function getJobFromParams(params: object) { - const result = BullParams.safeParse(params); +async function getJobFromParams(params: Params) { + const queue = await validateQueue(params.queue); - if (!result.success) { - throw new Response(null, { status: 404 }); - } - - const queue = QueueFromName[result.data.queue]; - - const job = await queue.getJob(result.data.id); + const job = await queue.getJob(params.id as string); if (!job) { throw new Response(null, { status: 404 }); @@ -152,7 +89,7 @@ export default function JobPage() { return ( @@ -161,51 +98,6 @@ export default function JobPage() { - - {general.state === 'delayed' && ( - } - backgroundColor="gray-100" - backgroundColorOnHover="gray-200" - name="action" - shape="square" - type="submit" - value={JobAction.PROMOTE} - /> - )} - {general.state === 'failed' && ( - } - backgroundColor="gray-100" - backgroundColorOnHover="gray-200" - name="action" - shape="square" - type="submit" - value={JobAction.RETRY} - /> - )} - {general.state === 'waiting' && ( - } - backgroundColor="gray-100" - backgroundColorOnHover="gray-200" - name="action" - shape="square" - type="submit" - value={JobAction.DUPLICATE} - /> - )} - } - backgroundColor="gray-100" - backgroundColorOnHover="gray-200" - name="action" - shape="square" - type="submit" - value={JobAction.REMOVE} - /> - - General diff --git a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.add.tsx b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.add.tsx index 20a74c29..3392e26a 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.add.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.add.tsx @@ -22,8 +22,7 @@ import { validateForm, } from '@oyster/ui'; -import { QueueFromName } from '@/admin-dashboard.server'; -import { BullQueue } from '@/admin-dashboard.ui'; +import { validateQueue } from '@/shared/bull'; import { Route } from '@/shared/constants'; import { commitSession, @@ -31,10 +30,6 @@ import { toast, } from '@/shared/session.server'; -const BullParams = z.object({ - queue: z.nativeEnum(BullQueue), -}); - export async function loader({ request }: LoaderFunctionArgs) { await ensureUserAuthenticated(request, { minimumRole: 'owner', @@ -75,9 +70,7 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ errors }, { status: 400 }); } - const { queue: queueName } = BullParams.parse(params); - - const queue = QueueFromName[queueName]; + const queue = await validateQueue(params.queue); await queue.add(data.name, data.data); @@ -85,14 +78,11 @@ export async function action({ params, request }: ActionFunctionArgs) { message: 'Added job.', }); - return redirect( - generatePath(Route['/bull/:queue/jobs'], { queue: queueName }), - { - headers: { - 'Set-Cookie': await commitSession(session), - }, - } - ); + return redirect(generatePath(Route['/bull/:queue'], { queue: queue.name }), { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); } export default function AddJobPage() { @@ -100,7 +90,7 @@ export default function AddJobPage() { return ( diff --git a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.tsx b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.tsx deleted file mode 100644 index 34e2f477..00000000 --- a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.jobs.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { - json, - type LoaderFunctionArgs, - type SerializeFrom, -} from '@remix-run/node'; -import { - Link, - Outlet, - useLoaderData, - useLocation, - useSearchParams, -} from '@remix-run/react'; -import dayjs from 'dayjs'; -import { Plus } from 'react-feather'; -import { generatePath } from 'react-router'; -import { match } from 'ts-pattern'; -import { z } from 'zod'; - -import { type ExtractValue } from '@oyster/types'; -import { - cx, - getIconButtonCn, - Pagination, - Table, - type TableColumnProps, - Text, -} from '@oyster/ui'; -import { toTitleCase } from '@oyster/utils'; - -import { QueueFromName } from '@/admin-dashboard.server'; -import { BullQueue, ListSearchParams } from '@/admin-dashboard.ui'; -import { Route } from '@/shared/constants'; -import { getTimezone } from '@/shared/cookies.server'; -import { ensureUserAuthenticated } from '@/shared/session.server'; - -const BullStatus = { - COMPLETED: 'completed', - DELAYED: 'delayed', - FAILED: 'failed', - WAITING: 'waiting', -} as const; - -type BullStatus = ExtractValue; - -const BullParams = z.object({ - queue: z.nativeEnum(BullQueue), -}); - -const BullSearchParams = ListSearchParams.pick({ - limit: true, - page: true, -}).extend({ - status: z.nativeEnum(BullStatus).catch('completed'), -}); - -export async function loader({ params, request }: LoaderFunctionArgs) { - await ensureUserAuthenticated(request, { - minimumRole: 'owner', - }); - - const url = new URL(request.url); - - const { queue: queueName } = BullParams.parse(params); - - const { limit, page, status } = BullSearchParams.parse( - Object.fromEntries(url.searchParams) - ); - - const queue = QueueFromName[queueName]; - - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit - 1; - - const [ - { - active: activeJobCount, - completed: completedJobCount, - delayed: delayedJobCount, - failed: failedJobCount, - waiting: waitingJobCount, - }, - _jobs, - ] = await Promise.all([ - queue.getJobCounts(), - queue.getJobs(status, startIndex, endIndex), - ]); - - const totalJobsOfStatus = match(status) - .with('completed', () => completedJobCount) - .with('delayed', () => delayedJobCount) - .with('failed', () => failedJobCount) - .with('waiting', () => waitingJobCount) - .exhaustive(); - - const timezone = getTimezone(request); - - const jobs = _jobs.map((job) => { - const { delay, id, name, processedOn, timestamp } = job.toJSON(); - - const format = 'MM/DD/YY @ h:mm:ss A'; - - return { - createdAt: dayjs(timestamp).tz(timezone).format(format), - id: id!, - name, - - ...(delay && { - delayedUntil: dayjs(timestamp) - .add(delay, 'ms') - .tz(timezone) - .format(format), - }), - ...(processedOn && { - processedAt: dayjs(processedOn).tz(timezone).format(format), - }), - }; - }); - - return json({ - activeJobCount, - completedJobCount, - delayedJobCount, - failedJobCount, - jobs, - limit, - page, - queue: queueName, - status, - totalJobsOfStatus, - waitingJobCount, - }); -} - -export default function JobsPage() { - const { - completedJobCount, - delayedJobCount, - failedJobCount, - waitingJobCount, - queue, - } = useLoaderData(); - - return ( - <> -
-
    - - - - -
- -
- - - -
-
- - - - - - ); -} - -type StatusNavigationItemProps = { - count: number; - status: BullStatus; -}; - -function StatusNavigationItem({ count, status }: StatusNavigationItemProps) { - const { status: currentStatus } = useLoaderData(); - - const [searchParams] = useSearchParams(); - - searchParams.set('status', status); - - return ( -
  • - - {toTitleCase(status)} ({count}) - -
  • - ); -} - -type JobInView = SerializeFrom['jobs'][number]; - -function JobsTable() { - const { jobs, queue, status } = useLoaderData(); - const { search } = useLocation(); - - const columns: TableColumnProps[] = [ - { - displayName: 'ID', - size: '80', - render: (job) => { - return ( - - {job.id} - - ); - }, - }, - { - displayName: 'Name', - size: '280', - render: (job) => { - return {job.name}; - }, - }, - { - displayName: 'Created At', - size: '200', - render: (job) => job.createdAt, - }, - { - displayName: 'Delayed Until', - size: '200', - render: (job) => job.delayedUntil || '-', - show: () => status == 'delayed', - }, - { - displayName: 'Processed At', - size: '200', - render: (job) => job.processedAt || '-', - }, - ]; - - return ; -} - -function JobsPagination() { - const { jobs, limit, page, totalJobsOfStatus } = - useLoaderData(); - - return ( - - ); -} - -export function ErrorBoundary() { - return Could not find queue.; -} diff --git a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.repeatables.add.tsx b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.repeatables.add.tsx index 32450e45..21112bea 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.repeatables.add.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.repeatables.add.tsx @@ -21,8 +21,7 @@ import { validateForm, } from '@oyster/ui'; -import { QueueFromName } from '@/admin-dashboard.server'; -import { BullQueue } from '@/admin-dashboard.ui'; +import { validateQueue } from '@/shared/bull'; import { Route } from '@/shared/constants'; import { commitSession, @@ -30,10 +29,6 @@ import { toast, } from '@/shared/session.server'; -const BullParams = z.object({ - queue: z.nativeEnum(BullQueue), -}); - export async function loader({ request }: LoaderFunctionArgs) { await ensureUserAuthenticated(request, { minimumRole: 'owner', @@ -60,9 +55,7 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ errors }, { status: 400 }); } - const { queue: queueName } = BullParams.parse(params); - - const queue = QueueFromName[queueName]; + const queue = await validateQueue(params.queue); await queue.add(data.name, undefined, { repeat: { @@ -75,14 +68,11 @@ export async function action({ params, request }: ActionFunctionArgs) { message: 'Added repeatable.', }); - return redirect( - generatePath(Route['/bull/:queue/repeatables'], { queue: queueName }), - { - headers: { - 'Set-Cookie': await commitSession(session), - }, - } - ); + return redirect(generatePath(Route['/bull/:queue'], { queue: queue.name }), { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); } export default function AddRepeatablePage() { @@ -90,7 +80,7 @@ export default function AddRepeatablePage() { return ( diff --git a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.repeatables.tsx b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.repeatables.tsx deleted file mode 100644 index daa10d09..00000000 --- a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.repeatables.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { - type ActionFunctionArgs, - json, - type LoaderFunctionArgs, - type SerializeFrom, -} from '@remix-run/node'; -import { - generatePath, - Link, - Outlet, - Form as RemixForm, - useLoaderData, -} from '@remix-run/react'; -import dayjs from 'dayjs'; -import { useState } from 'react'; -import { Plus, Trash } from 'react-feather'; -import { z } from 'zod'; - -import { - Dropdown, - getIconButtonCn, - Table, - type TableColumnProps, -} from '@oyster/ui'; - -import { QueueFromName } from '@/admin-dashboard.server'; -import { BullQueue } from '@/admin-dashboard.ui'; -import { Route } from '@/shared/constants'; -import { getTimezone } from '@/shared/cookies.server'; -import { - commitSession, - ensureUserAuthenticated, - toast, -} from '@/shared/session.server'; - -const BullParams = z.object({ - queue: z.nativeEnum(BullQueue), -}); - -export async function loader({ params, request }: LoaderFunctionArgs) { - await ensureUserAuthenticated(request, { - minimumRole: 'owner', - }); - - const { queue: queueName } = BullParams.parse(params); - const queue = QueueFromName[queueName]; - - const _repeatables = await queue.getRepeatableJobs(); - - const timezone = getTimezone(request); - const format = 'MM/DD/YY @ h:mm:ss A'; - - const repeatables = _repeatables.map((repeatable) => { - return { - id: repeatable.key, - name: repeatable.name, - next: dayjs(repeatable.next).tz(timezone).format(format), - pattern: repeatable.pattern, - tz: repeatable.tz, - }; - }); - - return json({ - queue: queueName, - repeatables, - }); -} - -export async function action({ params, request }: ActionFunctionArgs) { - const session = await ensureUserAuthenticated(request, { - minimumRole: 'owner', - }); - - const form = await request.formData(); - const { id } = Object.fromEntries(form); - - const { queue: queueName } = BullParams.parse(params); - const queue = QueueFromName[queueName]; - - await queue.removeRepeatableByKey(id as string); - - toast(session, { - message: 'Removed repeatable.', - }); - - return json( - {}, - { - headers: { - 'Set-Cookie': await commitSession(session), - }, - } - ); -} - -export default function RepeatablesPage() { - const { queue } = useLoaderData(); - - return ( - <> -
    -
    - - - -
    -
    - - - - - ); -} - -type RepeatableInView = SerializeFrom['repeatables'][number]; - -function RepeatablesTable() { - const { repeatables } = useLoaderData(); - - const columns: TableColumnProps[] = [ - { - displayName: 'Name', - size: '280', - render: (repeatable) => { - return {repeatable.name}; - }, - }, - { - displayName: 'Pattern', - size: '160', - render: (repeatable) => { - return {repeatable.pattern}; - }, - }, - { - displayName: 'Next Job', - size: '200', - render: (repeatable) => repeatable.next, - }, - { - displayName: 'Timezone', - size: '200', - render: (repeatable) => repeatable.tz, - }, - ]; - - return ( -
    - ); -} - -function RepeatableDropdown({ id }: RepeatableInView) { - const [open, setOpen] = useState(false); - - function onClose() { - setOpen(false); - } - - function onOpen() { - setOpen(true); - } - - return ( - - {open && ( - - - - - - - - - - )} - - - - ); -} diff --git a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.tsx b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.tsx index ea8f7063..03a8246e 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.bull.$queue.tsx @@ -1,23 +1,70 @@ -import { json, type LoaderFunctionArgs } from '@remix-run/node'; -import { generatePath, NavLink, Outlet, useLoaderData } from '@remix-run/react'; +import { + type ActionFunctionArgs, + json, + type LoaderFunctionArgs, + redirect, + type SerializeFrom, +} from '@remix-run/node'; +import { + Link, + Outlet, + Form as RemixForm, + useLoaderData, + useLocation, + useNavigate, +} from '@remix-run/react'; +import dayjs from 'dayjs'; +import { useState } from 'react'; +import { + ArrowUp, + Copy, + Menu, + Plus, + RefreshCw, + Repeat, + Trash2, +} from 'react-feather'; +import { generatePath } from 'react-router'; import { match } from 'ts-pattern'; import { z } from 'zod'; -import { type ExtractValue } from '@oyster/types'; +import { listQueueNames } from '@oyster/core/admin-dashboard.server'; +import { + cx, + Dashboard, + Dropdown, + IconButton, + Pagination, + Pill, + Select, + Table, + type TableColumnProps, +} from '@oyster/ui'; +import { toTitleCase } from '@oyster/utils'; -import { BullQueue } from '@/admin-dashboard.ui'; +import { validateQueue } from '@/shared/bull'; import { Route } from '@/shared/constants'; -import { ensureUserAuthenticated } from '@/shared/session.server'; +import { getTimezone } from '@/shared/cookies.server'; +import { + commitSession, + ensureUserAuthenticated, + toast, +} from '@/shared/session.server'; -const BullType = { - JOB: 'job', - REPEATABLE: 'repeatable', -} as const; - -type BullType = ExtractValue; - -const BullParams = z.object({ - queue: z.nativeEnum(BullQueue), +const BullSearchParams = z.object({ + limit: z.coerce.number().min(10).max(100).catch(25), + page: z.coerce.number().min(1).catch(1), + status: z + .enum([ + 'active', + 'all', + 'completed', + 'delayed', + 'failed', + 'paused', + 'waiting', + ]) + .catch('all'), }); export async function loader({ params, request }: LoaderFunctionArgs) { @@ -25,61 +72,628 @@ export async function loader({ params, request }: LoaderFunctionArgs) { minimumRole: 'owner', }); - const paramsResult = BullParams.safeParse(params); + const [queue, queues] = await Promise.all([ + validateQueue(params.queue), + listQueueNames(), + ]); - if (!paramsResult.success) { - throw new Response(null, { status: 404 }); - } + const { searchParams } = new URL(request.url); + + const { limit, page, status } = BullSearchParams.parse( + Object.fromEntries(searchParams) + ); + + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit - 1; - const { queue } = paramsResult.data; + const [counts, _jobs, _repeatables] = await Promise.all([ + queue.getJobCounts(), + queue.getJobs(status === 'all' ? undefined : status, startIndex, endIndex), + queue.getRepeatableJobs(), + ]); + + const tz = getTimezone(request); + const format = 'MM/DD/YY @ h:mm:ss A'; + + const jobs = await Promise.all( + _jobs.map(async (job) => { + const { attemptsMade, delay, id, name, processedOn, timestamp } = + job.toJSON(); + + return { + attemptsMade, + createdAt: dayjs(timestamp).tz(tz).format(format), + id: id as string, + name, + status: await job.getState(), + + ...(delay && { + delayedUntil: dayjs(timestamp).add(delay, 'ms').tz(tz).format(format), + }), + + ...(processedOn && { + processedAt: dayjs(processedOn).tz(tz).format(format), + }), + }; + }) + ); + + const repeatables = _repeatables.map((repeatable) => { + return { + id: repeatable.key, + name: repeatable.name, + next: dayjs(repeatable.next).tz(tz).format(format), + pattern: repeatable.pattern, + tz: repeatable.tz, + }; + }); + + const allJobsCount = Object.values(counts).reduce((result, count) => { + return result + count; + }, 0); return json({ - queue, + counts: [ + { status: 'all', count: allJobsCount }, + { status: 'active', count: counts.active }, + { status: 'completed', count: counts.completed }, + { status: 'delayed', count: counts.delayed }, + { status: 'failed', count: counts.failed }, + { status: 'paused', count: counts.paused }, + { status: 'waiting', count: counts.waiting }, + ], + filteredJobsCount: status === 'all' ? allJobsCount : counts[status], + jobs, + limit, + page, + queue: queue.name, + queues, + repeatables, + status, + }); +} + +const QueueAction = { + 'job.duplicate': 'job.duplicate', + 'job.promote': 'job.promote', + 'job.remove': 'job.remove', + 'job.retry': 'job.retry', + 'queue.clean': 'queue.clean', + 'queue.obliterate': 'queue.obliterate', + 'repeatable.remove': 'repeatable.remove', +} as const; + +const QueueForm = z.discriminatedUnion('action', [ + z.object({ + action: z.literal(QueueAction['job.duplicate']), + id: z.string().trim().min(1), + }), + z.object({ + action: z.literal(QueueAction['job.promote']), + id: z.string().trim().min(1), + }), + z.object({ + action: z.literal(QueueAction['job.remove']), + id: z.string().trim().min(1), + }), + z.object({ + action: z.literal(QueueAction['job.retry']), + id: z.string().trim().min(1), + }), + z.object({ + action: z.literal(QueueAction['queue.clean']), + }), + z.object({ + action: z.literal(QueueAction['queue.obliterate']), + }), + z.object({ + action: z.literal(QueueAction['repeatable.remove']), + key: z.string().trim().min(1), + }), +]); + +export async function action({ params, request }: ActionFunctionArgs) { + const session = await ensureUserAuthenticated(request, { + minimumRole: 'owner', + }); + + const queue = await validateQueue(params.queue); + + const form = await request.formData(); + + const result = QueueForm.safeParse(Object.fromEntries(form)); + + if (!result.success) { + throw new Response(null, { + status: 400, + }); + } + + await match(result.data) + .with({ action: 'job.duplicate' }, async ({ id }) => { + const job = await queue.getJob(id); + + if (!job) { + throw new Response(null, { status: 404 }); + } + + return queue.add(job.name, job.data); + }) + .with({ action: 'job.promote' }, async ({ id }) => { + const job = await queue.getJob(id); + + if (!job) { + throw new Response(null, { status: 404 }); + } + + return job.promote(); + }) + .with({ action: 'job.remove' }, async ({ id }) => { + const job = await queue.getJob(id); + + if (!job) { + throw new Response(null, { status: 404 }); + } + + return job.remove(); + }) + .with({ action: 'job.retry' }, async ({ id }) => { + const job = await queue.getJob(id); + + if (!job) { + throw new Response(null, { status: 404 }); + } + + return job.retry(); + }) + .with({ action: 'queue.clean' }, async () => { + return queue.clean(0, 0, 'completed'); + }) + .with({ action: 'queue.obliterate' }, async () => { + await queue.obliterate(); + }) + .with({ action: 'repeatable.remove' }, async ({ key }) => { + return queue.removeRepeatableByKey(key); + }) + .exhaustive(); + + toast(session, { + message: 'Done!', }); + + const init: ResponseInit = { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }; + + return result.data.action === 'queue.obliterate' + ? redirect(Route['/bull'], init) + : json({}, init); } -export default function QueueLayout() { +export default function QueuePage() { + const { repeatables } = useLoaderData(); + return ( <> -
      - - -
    + + 🐂 Bull + +
    + + +
    +
    + + {!!repeatables.length && ( +
    + +
    + )} + + + + ); } -type TypeNavigationItemProps = { - type: BullType; -}; +function QueueSelector() { + const { queue, queues } = useLoaderData(); + const navigate = useNavigate(); -function TypeNavigationItem({ type }: TypeNavigationItemProps) { + return ( + + ); +} + +function QueueDropdown() { const { queue } = useLoaderData(); + const [open, setOpen] = useState(false); - const basePathname = match(type) - .with('job', () => Route['/bull/:queue/jobs']) - .with('repeatable', () => Route['/bull/:queue/repeatables']) - .exhaustive(); + function onClose() { + setOpen(false); + } - const label = match(type) - .with('job', () => 'Jobs') - .with('repeatable', () => 'Repeatables') - .exhaustive(); + function onClick() { + setOpen(true); + } return ( -
  • - - {label} - -
  • + + } + onClick={onClick} + shape="square" + /> + + {open && ( + + + + + Add Job + + + + + Add Repeatable + + + + + + + + + + + + + + + )} + + ); +} + +type RepeatableInView = SerializeFrom['repeatables'][number]; + +function RepeatablesTable() { + const { repeatables } = useLoaderData(); + + const columns: TableColumnProps[] = [ + { + displayName: 'Name', + size: '280', + render: (repeatable) => { + return {repeatable.name}; + }, + }, + { + displayName: 'Pattern', + size: '160', + render: (repeatable) => { + return {repeatable.pattern}; + }, + }, + { + displayName: 'Next Job', + size: '200', + render: (repeatable) => repeatable.next, + }, + { + displayName: 'Timezone', + size: '200', + render: (repeatable) => repeatable.tz, + }, + ]; + + return ( +
    ); } -export function ErrorBoundary() { - return <>; +function RepeatableDropdown({ id }: RepeatableInView) { + const [open, setOpen] = useState(false); + + function onClose() { + setOpen(false); + } + + function onOpen() { + setOpen(true); + } + + return ( + + {open && ( + + + + + + + + + + + + )} + + + + ); +} + +function JobStatusNavigation() { + const { counts, status: currentStatus } = useLoaderData(); + const { search } = useLocation(); + + return ( +
      + {counts.map(({ count, status }) => { + const searchParams = new URLSearchParams(search); + + searchParams.set('status', status); + + return ( +
    • + + {toTitleCase(status)} ({count}) + +
    • + ); + })} +
    + ); +} + +type JobInView = SerializeFrom['jobs'][number]; + +function JobsTable() { + const { jobs, queue, status } = useLoaderData(); + const { search } = useLocation(); + + const columns: TableColumnProps[] = [ + { + displayName: 'ID', + size: '120', + render: (job) => { + return ( + + {job.id} + + ); + }, + }, + { + displayName: 'Name', + size: '280', + render: (job) => { + return {job.name}; + }, + }, + { + displayName: 'Status', + size: '120', + render: (job) => { + return match(job.status) + .with('active', () => { + return Active; + }) + .with('completed', () => { + return Completed; + }) + .with('delayed', () => { + return Delayed; + }) + .with('failed', () => { + return Failed; + }) + .with('waiting', 'waiting-children', () => { + return Waiting; + }) + .with('prioritized', 'unknown', () => { + return '-'; + }) + .exhaustive(); + }, + }, + { + displayName: '# of Attempts', + size: '120', + render: (job) => job.attemptsMade, + }, + { + displayName: 'Created At', + size: '200', + render: (job) => job.createdAt, + }, + { + displayName: 'Delayed Until', + size: '200', + render: (job) => job.delayedUntil || '-', + show: () => status == 'delayed', + }, + { + displayName: 'Processed At', + size: '200', + render: (job) => job.processedAt || '-', + }, + ]; + + return ( +
    + ); +} + +function JobDropdown({ id, status }: JobInView) { + const [open, setOpen] = useState(false); + + function onClose() { + setOpen(false); + } + + function onOpen() { + setOpen(true); + } + + return ( + + {open && ( + + + {status === 'failed' && ( + + + + + + + + )} + + + + + + + + + + {(status === 'delayed' || status === 'waiting') && ( + + + + + + + + )} + + + + + + + + + + + )} + + + + ); +} + +function JobsPagination() { + const { jobs, limit, page, filteredJobsCount } = + useLoaderData(); + + return ( + + ); } diff --git a/apps/admin-dashboard/app/routes/_dashboard.bull._index.tsx b/apps/admin-dashboard/app/routes/_dashboard.bull._index.tsx index 9fe9e7a6..a5624980 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.bull._index.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.bull._index.tsx @@ -1,5 +1,6 @@ -import { json, type LoaderFunctionArgs } from '@remix-run/node'; +import { type LoaderFunctionArgs, redirect } from '@remix-run/node'; +import { listQueueNames } from '@/admin-dashboard.server'; import { ensureUserAuthenticated } from '@/shared/session.server'; export async function loader({ request }: LoaderFunctionArgs) { @@ -7,9 +8,7 @@ export async function loader({ request }: LoaderFunctionArgs) { minimumRole: 'owner', }); - return json({}); -} + const queues = await listQueueNames(); -export default function BullPage() { - return null; + return redirect(`/bull/${queues[0]}`); } diff --git a/apps/admin-dashboard/app/routes/_dashboard.bull.tsx b/apps/admin-dashboard/app/routes/_dashboard.bull.tsx deleted file mode 100644 index c4a60fa6..00000000 --- a/apps/admin-dashboard/app/routes/_dashboard.bull.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { json, type LoaderFunctionArgs } from '@remix-run/node'; -import { NavLink, Outlet } from '@remix-run/react'; -import { generatePath } from 'react-router'; - -import { Dashboard } from '@oyster/ui'; -import { toTitleCase } from '@oyster/utils'; - -import { BullQueue } from '@/admin-dashboard.ui'; -import { Route } from '@/shared/constants'; -import { ensureUserAuthenticated } from '@/shared/session.server'; - -export async function loader({ request }: LoaderFunctionArgs) { - await ensureUserAuthenticated(request, { - minimumRole: 'owner', - }); - - return json({}); -} - -const QUEUES = Object.values(BullQueue); - -export default function BullPage() { - return ( - <> -
    - 🐂 Bull -
    - -
    -
      - {QUEUES.map((queue) => { - return ( -
    • - - {toTitleCase(queue)} - -
    • - ); - })} -
    - -
    - -
    -
    - - ); -} diff --git a/apps/admin-dashboard/app/routes/_dashboard.schools.$id.edit.tsx b/apps/admin-dashboard/app/routes/_dashboard.schools.$id.edit.tsx index 32548e13..277c9a48 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.schools.$id.edit.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.schools.$id.edit.tsx @@ -16,6 +16,7 @@ import { SchoolCityField, SchoolNameField, SchoolStateField, + SchoolTagsField, SchoolZipField, } from '@oyster/core/education.ui'; import { Button, getErrors, Modal, validateForm } from '@oyster/ui'; @@ -31,7 +32,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { await ensureUserAuthenticated(request); const school = await getSchool({ - select: ['name', 'addressCity', 'addressState', 'addressZip'], + select: ['name', 'addressCity', 'addressState', 'addressZip', 'tags'], where: { id: params.id as string }, }); @@ -47,8 +48,13 @@ export async function loader({ params, request }: LoaderFunctionArgs) { export async function action({ params, request }: ActionFunctionArgs) { const session = await ensureUserAuthenticated(request); + const form = await request.formData(); + const { data, errors, ok } = await validateForm( - request, + { + ...Object.fromEntries(form), + tags: form.getAll('tags').filter(Boolean), + }, UpdateSchoolInput.omit({ id: true }) ); @@ -62,6 +68,7 @@ export async function action({ params, request }: ActionFunctionArgs) { addressZip: data.addressZip, id: params.id as string, name: data.name, + tags: data.tags, }); toast(session, { @@ -88,6 +95,7 @@ export default function EditSchoolModal() { + { return eb .selectFrom('students') @@ -167,6 +171,36 @@ function SchoolsTable() { render: (school) => school.students, size: '120', }, + { + displayName: 'Tag(s)', + render: (school) => { + if (!school.tags?.length) { + return ''; + } + + return ( +
      + {school.tags.map((tag) => { + return ( +
    • + {match(tag) + .with(SchoolTag.HBCU, () => { + return HBCU; + }) + .with(SchoolTag.HSI, () => { + return HSI; + }) + .otherwise(() => { + return {tag}; + })} +
    • + ); + })} +
    + ); + }, + size: '120', + }, ]; return ( diff --git a/apps/admin-dashboard/app/shared/bull.ts b/apps/admin-dashboard/app/shared/bull.ts new file mode 100644 index 00000000..97363688 --- /dev/null +++ b/apps/admin-dashboard/app/shared/bull.ts @@ -0,0 +1,26 @@ +import { getQueue, listQueueNames } from '@/admin-dashboard.server'; + +/** + * Validates a queue name and returns the corresponding queue instance. + * + * This is intended to be used within loaders/actions to validate the `queue` + * name parameter. + * + * @param queueName - The name of the queue to validate. + * @returns The corresponding queue instance. + */ +export async function validateQueue(queueName: unknown) { + const name = queueName as string; + const queueNames = await listQueueNames(); + + if (!queueNames.includes(name)) { + throw new Response(null, { + status: 404, + statusText: 'Queue not found.', + }); + } + + const queue = getQueue(name); + + return queue; +} diff --git a/apps/admin-dashboard/app/shared/constants.ts b/apps/admin-dashboard/app/shared/constants.ts index 24b6c2d1..0f0a9adf 100644 --- a/apps/admin-dashboard/app/shared/constants.ts +++ b/apps/admin-dashboard/app/shared/constants.ts @@ -2,16 +2,15 @@ const ROUTES = [ '/', '/admins', '/admins/add', + '/admins/:id/remove', '/applications', '/applications/:id', '/applications/:id/accept', '/applications/:id/email', '/bull', '/bull/:queue', - '/bull/:queue/jobs', '/bull/:queue/jobs/add', '/bull/:queue/jobs/:id', - '/bull/:queue/repeatables', '/bull/:queue/repeatables/add', '/events', '/events/create', diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index fcc84ce1..80d35e6e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -4,25 +4,7 @@ import cors from 'cors'; import express from 'express'; import helmet from 'helmet'; -import { - airtableWorker, - applicationWorker, - educationWorker, - emailMarketingWorker, - eventWorker, - feedWorker, - gamificationWorker, - memberEmailWorker, - memberWorker, - notificationWorker, - onboardingSessionWorker, - oneTimeCodeWorker, - profileWorker, - slackWorker, - surveyWorker, - swagPackWorker, - workExperienceWorker, -} from '@oyster/core/api'; +import { startBullWorkers } from '@oyster/core/api'; import { healthRouter } from './routers/health.router'; import { oauthRouter } from './routers/oauth.router'; @@ -73,31 +55,11 @@ async function bootstrap() { app.use(Sentry.Handlers.errorHandler()); - initializeBullWorkers(); + startBullWorkers(); app.listen(ENV.PORT, () => { console.log('API is up and running! 🚀'); }); } -function initializeBullWorkers() { - airtableWorker.run(); - applicationWorker.run(); - educationWorker.run(); - emailMarketingWorker.run(); - eventWorker.run(); - feedWorker.run(); - gamificationWorker.run(); - memberWorker.run(); - memberEmailWorker.run(); - notificationWorker.run(); - onboardingSessionWorker.run(); - oneTimeCodeWorker.run(); - profileWorker.run(); - slackWorker.run(); - surveyWorker.run(); - swagPackWorker.run(); - workExperienceWorker.run(); -} - bootstrap(); diff --git a/apps/member-profile/app/entry.server.tsx b/apps/member-profile/app/entry.server.tsx index b8b3a9aa..a0c966c2 100644 --- a/apps/member-profile/app/entry.server.tsx +++ b/apps/member-profile/app/entry.server.tsx @@ -12,7 +12,7 @@ import isbot from 'isbot'; import { renderToPipeableStream } from 'react-dom/server'; import { PassThrough } from 'stream'; -import { getCookie, iife } from '@oyster/utils'; +import { getCookie, run } from '@oyster/utils'; import { ENV } from '@/shared/constants.server'; @@ -21,7 +21,7 @@ import { ENV } from '@/shared/constants.server'; // and crash the application. import '@/shared/constants.server'; -iife(() => { +run(() => { dayjs.extend(utc); dayjs.extend(relativeTime); dayjs.extend(timezone); diff --git a/apps/member-profile/app/routes/_profile.companies_.$id.tsx b/apps/member-profile/app/routes/_profile.companies_.$id.tsx index 604e4a55..80db5f1c 100644 --- a/apps/member-profile/app/routes/_profile.companies_.$id.tsx +++ b/apps/member-profile/app/routes/_profile.companies_.$id.tsx @@ -53,6 +53,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { }), listCompanyReviews({ + memberId: user(session), select: [ 'companyReviews.createdAt', 'companyReviews.id', @@ -253,6 +254,8 @@ function ReviewsList() { date={review.date} editable={review.editable} employmentType={review.employmentType as EmploymentType} + hasUpvoted={review.upvoted as boolean} + id={review.id} locationCity={review.locationCity} locationState={review.locationState} locationType={review.locationType as LocationType} @@ -265,6 +268,7 @@ function ReviewsList() { reviewerProfilePicture={review.reviewerProfilePicture} text={review.text} title={review.title || ''} + upvotesCount={review.upvotes} workExperienceId={review.workExperienceId || ''} anonymous={review.anonymous} /> diff --git a/apps/member-profile/app/routes/_profile.directory.tsx b/apps/member-profile/app/routes/_profile.directory.tsx index e4ae66e7..59455019 100644 --- a/apps/member-profile/app/routes/_profile.directory.tsx +++ b/apps/member-profile/app/routes/_profile.directory.tsx @@ -10,14 +10,16 @@ import { Form as RemixForm, useLoaderData, } from '@remix-run/react'; +import dayjs from 'dayjs'; import { useState } from 'react'; import { Filter, Plus } from 'react-feather'; import { match } from 'ts-pattern'; +import { type z } from 'zod'; import { SchoolCombobox } from '@oyster/core/education.ui'; import { CityCombobox } from '@oyster/core/location.ui'; import { db } from '@oyster/db'; -import { type ExtractValue } from '@oyster/types'; +import { type ExtractValue, ISO8601Date } from '@oyster/types'; import { Button, Dashboard, @@ -30,7 +32,7 @@ import { Text, useSearchParams, } from '@oyster/ui'; -import { toTitleCase } from '@oyster/utils'; +import { run, toTitleCase } from '@oyster/utils'; import { listMembersInDirectory } from '@/member-profile.server'; import { @@ -59,7 +61,11 @@ type DirectoryFilterKey = ExtractValue; const DirectorySearchParams = ListSearchParams.pick({ limit: true, page: true, -}).merge(ListMembersInDirectoryWhere); +}) + .merge(ListMembersInDirectoryWhere) + .extend({ joinedDirectoryDate: ISO8601Date.optional().catch(undefined) }); + +type DirectorySearchParams = z.infer; export async function loader({ request }: LoaderFunctionArgs) { await ensureUserAuthenticated(request); @@ -74,7 +80,25 @@ export async function loader({ request }: LoaderFunctionArgs) { const [filters, { members, totalCount }] = await Promise.all([ getAppliedFilters(searchParams), - listMembersInDirectory({ limit, page, where }), + listMembersInDirectory({ + limit, + page, + where: { + ...where, + ...(searchParams.joinedDirectoryDate && + run(() => { + const date = dayjs(searchParams.joinedDirectoryDate).tz( + 'America/Los_Angeles', + true + ); + + return { + joinedDirectoryAfter: date.startOf('day').toDate(), + joinedDirectoryBefore: date.endOf('day').toDate(), + }; + })), + }, + }), ]); return json({ @@ -89,11 +113,12 @@ export async function loader({ request }: LoaderFunctionArgs) { async function getAppliedFilters( searchParams: Pick< - ListMembersInDirectoryWhere, + DirectorySearchParams, | 'company' | 'ethnicity' | 'graduationYear' | 'hometown' + | 'joinedDirectoryDate' | 'location' | 'school' > @@ -139,14 +164,35 @@ async function getAppliedFilters( .exhaustive(), ]); - return { - company, - ethnicity, - graduationYear: searchParams.graduationYear, - hometown: searchParams.hometown, - location: searchParams.location, - school, - }; + const result: Array<{ + name: string; + param: string; + value: string | number | null | undefined; + }> = [ + { name: 'Company', param: keys.company, value: company }, + { name: 'Ethnicity', param: keys.ethnicity, value: ethnicity }, + { + name: 'Graduation Year', + param: keys.graduationYear, + value: searchParams.graduationYear, + }, + { name: 'Hometown', param: keys.hometown, value: searchParams.hometown }, + { + name: 'Joined Directory On', + param: 'joinedDirectoryDate', + value: searchParams.joinedDirectoryDate + ? dayjs(searchParams.joinedDirectoryDate) + .tz('America/Los_Angeles', true) + .format('M/D/YY') + : undefined, + }, + { name: 'Location', param: keys.location, value: searchParams.location }, + { name: 'School', param: keys.school, value: school }, + ].filter((item) => { + return !!item.value; + }); + + return result; } const keys = ListMembersInDirectoryWhere.keyof().enum; @@ -349,35 +395,33 @@ function AppliedFilterGroup() { return (
      - {Object.entries(filters) - .filter(([_, value]) => !!value) - .map(([key, value]) => { - const url = new URL(_url); + {filters.map(({ name, param, value }) => { + const url = new URL(_url); - url.searchParams.delete(key); + url.searchParams.delete(param); - if (key === keys.hometown) { - url.searchParams.delete(keys.hometownLatitude); - url.searchParams.delete(keys.hometownLongitude); - } + if (param === keys.hometown) { + url.searchParams.delete(keys.hometownLatitude); + url.searchParams.delete(keys.hometownLongitude); + } - if (key === keys.location) { - url.searchParams.delete(keys.locationLatitude); - url.searchParams.delete(keys.locationLongitude); - } + if (param === keys.location) { + url.searchParams.delete(keys.locationLatitude); + url.searchParams.delete(keys.locationLongitude); + } - // When the origin is included, it is an absolute URL but we need it - // to be relative so that the whole page doesn't refresh. - const href = url.href.replace(url.origin, ''); + // When the origin is included, it is an absolute URL but we need it + // to be relative so that the whole page doesn't refresh. + const href = url.href.replace(url.origin, ''); - return ( -
    • - - {toTitleCase(key)}: {value} - -
    • - ); - })} + return ( +
    • + + {name}: {value} + +
    • + ); + })}
    ); } diff --git a/apps/member-profile/app/routes/_profile.home.activation.tsx b/apps/member-profile/app/routes/_profile.home.activation.tsx new file mode 100644 index 00000000..c4e1ae6d --- /dev/null +++ b/apps/member-profile/app/routes/_profile.home.activation.tsx @@ -0,0 +1,429 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node'; +import { Link, useLoaderData } from '@remix-run/react'; +import dayjs from 'dayjs'; +import React, { type PropsWithChildren, useState } from 'react'; +import { CheckCircle, ChevronDown, ChevronUp, XCircle } from 'react-feather'; +import { match } from 'ts-pattern'; + +import { db } from '@oyster/db'; +import { type ActivationRequirement } from '@oyster/types'; +import { Modal, Pill, Text } from '@oyster/ui'; +import { run } from '@oyster/utils'; + +import { Route } from '@/shared/constants'; +import { getTimezone } from '@/shared/cookies.server'; +import { ensureUserAuthenticated, user } from '@/shared/session.server'; + +type ActivationStatus = 'activated' | 'claimed' | 'ineligible' | 'in_progress'; + +export async function loader({ request }: LoaderFunctionArgs) { + const session = await ensureUserAuthenticated(request); + + const member = await db + .selectFrom('students') + .select([ + 'acceptedAt', + 'activatedAt', + 'activationRequirementsCompleted', + 'claimedSwagPackAt', + ]) + .where('id', '=', user(session)) + .executeTakeFirst(); + + if (!member) { + throw new Response(null, { status: 404 }); + } + + const tz = getTimezone(request); + + const format = 'MMMM D, YYYY'; + + const acceptedAt = member.acceptedAt + ? dayjs(member.acceptedAt).tz(tz).format(format) + : undefined; + + const claimedSwagPackAt = member.claimedSwagPackAt + ? dayjs(member.claimedSwagPackAt).tz(tz).format(format) + : undefined; + + const status = run((): ActivationStatus => { + if (member.claimedSwagPackAt) { + return 'claimed'; + } + + // This is the date that the activation flow was updated for new members. + if (member.acceptedAt < new Date('2023-06-09')) { + return 'ineligible'; + } + + if (member.activatedAt) { + return 'activated'; + } + + return 'in_progress'; + }); + + return json({ + acceptedAt, + claimedSwagPackAt, + requirementsCompleted: member.activationRequirementsCompleted, + status, + }); +} + +export default function ActivationModal() { + const { status } = useLoaderData(); + + const pill = match(status) + .with('activated', () => { + return Activated; + }) + .with('claimed', () => { + return Claimed Swag Pack; + }) + .with('ineligible', () => { + return Ineligible; + }) + .with('in_progress', () => { + return In Progress; + }) + .exhaustive(); + + const body = match(status) + .with('activated', () => ) + .with('claimed', () => ) + .with('ineligible', () => ) + .with('in_progress', () => ) + .exhaustive(); + + return ( + + + Activation ✅ + + + + Status: {pill} + +
    {body}
    +
    + ); +} + +function ActivatedState() { + return ( + <> + + Great news -- you're activated and eligible to{' '} + + claim your FREE swag pack + + ! 🎉 + + + + + ); +} + +function ClaimedState() { + const { claimedSwagPackAt } = useLoaderData(); + + return ( + <> + + You claimed your swag pack on{' '} + {claimedSwagPackAt}. If you're + interested in purchasing more swag, check out the official{' '} + + ColorStack Merch Store + + ! + + + + + ); +} + +function IneligibleState() { + const { acceptedAt } = useLoaderData(); + + return ( + + ColorStack launched the activation flow for members who joined after June + 9th, 2023. You joined on{' '} + {acceptedAt}, so unfortunately the + activation flow does not apply to you and you are not eligible to claim a + swag pack. So sorry about that! + + ); +} + +function NotActivatedState() { + const { requirementsCompleted } = useLoaderData(); + + return ( + <> + + You've completed {requirementsCompleted.length}/6 activation + requirements. Once you hit all 6, you will be eligible to claim your + FREE swag pack! 👀 + + + + + ); +} + +function ActivationList() { + const emailLink = ( + + here + + ); + + const onboardingLink = ( + + onboarding session + + ); + + const upcomingEventsLink = ( + + upcoming events + + ); + + return ( +
      + + + We host a monthly virtual event at the end of every month called Fam + Friday. Stay tuned to the {upcomingEventsLink} page -- we typically + open registration around the 3rd week of every month! + + + + + + + It is likely that you joined the event with an email address + that is not on your ColorStack account, so we couldn't associate + the attendance with you. Please add that email to your account{' '} + {emailLink}, then it should update! + + } + /> + + + + + + Attend an {onboardingLink} to learn more about ColorStack and meet + other members! + + + + + + + + + + You are automatically subscribed to our weekly newsletter -- new + issues are typically sent on Wednesdays. If you haven't received a + newsletter after 2 weeks of being in ColorStack, let us know. + + + + + + + + + + Introduce Yourself in{' '} + + #introductions + + + } + requirement="send_introduction_message" + > + + This is an easy one -- introduce yourself in the Slack! Please follow + the template others have used in the #introductions channel. + + + + + Answer a QOTD in{' '} + + #announcements + + + } + requirement="reply_to_announcement_message" + > + + We post announcements typically 2x a week. Look out for a question of + the day (QOTD) and answer it in the thread! + + + + + + + + + + We highly value engagement and helping others in the community! Reply + to 2 threads in ANY channel that other ColorStack members have posted. + + +
    + ); +} + +// Activation Item + +type ActivationItemProps = PropsWithChildren<{ + label: React.ReactNode; + requirement: ActivationRequirement; +}>; + +const ActivationItem = ({ + children, + label, + requirement, +}: ActivationItemProps) => { + // TODO: We should make an official `Accordion` component in our `ui` library + // but for now, this will do. + const [expanded, setExpanded] = useState(false); + const { requirementsCompleted } = useLoaderData(); + + const completed = requirementsCompleted.includes(requirement); + + return ( +
  • + + + {expanded && ( +
    +
    + +
    + +
    {children}
    +
    + )} +
  • + ); +}; + +ActivationItem.Description = function Description({ + children, +}: PropsWithChildren) { + return ( + + {children} + + ); +}; + +type QuestionProps = { + answer: React.ReactNode; + question: React.ReactNode; +}; + +ActivationItem.Question = function Question({ + answer, + question, +}: QuestionProps) { + return ( +
  • + + Q: + + +
    + + {question} + + + + {answer} + +
    +
  • + ); +}; + +ActivationItem.QuestionList = function QuestionList({ + children, +}: PropsWithChildren) { + return
      {children}
    ; +}; diff --git a/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx b/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx index 180646c9..34decaa6 100644 --- a/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx +++ b/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx @@ -54,16 +54,12 @@ export async function loader({ request }: LoaderFunctionArgs) { }); } -const ClaimSwagPackFormData = ClaimSwagPackInput.omit({ - studentId: true, -}); - export async function action({ request }: ActionFunctionArgs) { const session = await ensureUserAuthenticated(request); const { data, errors, ok } = await validateForm( request, - ClaimSwagPackFormData + ClaimSwagPackInput.omit({ studentId: true }) ); if (!ok) { @@ -71,8 +67,9 @@ export async function action({ request }: ActionFunctionArgs) { } try { - await claimSwagPack({ + const result = await claimSwagPack({ addressCity: data.addressCity, + addressCountry: data.addressCountry, addressLine1: data.addressLine1, addressLine2: data.addressLine2, addressState: data.addressState, @@ -80,6 +77,10 @@ export async function action({ request }: ActionFunctionArgs) { studentId: user(session), }); + if (!result.ok) { + return json({ error: result.error }, { status: result.code }); + } + return redirect(Route['/home/claim-swag-pack/confirmation']); } catch (e) { reportException(e); @@ -91,7 +92,7 @@ export async function action({ request }: ActionFunctionArgs) { } } -const keys = ClaimSwagPackFormData.keyof().enum; +const keys = ClaimSwagPackInput.keyof().enum; export default function ClaimSwagPack() { const { inventoryPromise } = useLoaderData(); @@ -142,6 +143,19 @@ function ClaimSwagPackForm() { Let us know where to send your swag pack!
    + + + + - (); + const showActivationCard = + !!student.joinedAfterActivation && + !student.activatedAt && + !student.claimedSwagPackAt; + const showOnboardingCard = !!student.joinedAfterActivation && !student.onboardedAt; @@ -243,9 +242,10 @@ export default function HomeLayout() { <> Hey, {student.firstName}! 👋 - {(showOnboardingCard || showSwagCard) && ( + {(showActivationCard || showOnboardingCard || showSwagCard) && ( <>
    + {showActivationCard && } {showSwagCard && } {showOnboardingCard && }
    @@ -267,8 +267,6 @@ export default function HomeLayout() { - {student.joinedAfterActivation && } - @@ -376,7 +374,7 @@ function ClaimSwagPackCard() { function OnboardingSessionCard() { return ( - Attend an Onboarding Session + Attend an Onboarding Session 📆 Attend an onboarding session to learn more about ColorStack and meet @@ -385,9 +383,9 @@ function OnboardingSessionCard() { Book Onboarding Session @@ -396,66 +394,31 @@ function OnboardingSessionCard() { ); } -const ActivationRequirementTitle: Record = { - attend_event: 'Attend an Event', - attend_onboarding: 'Attend an Onboarding Session', - open_email_campaign: 'Open a Weekly Newsletter', - reply_to_announcement_message: 'Answer a QOTD in #announcements', - reply_to_other_messages: 'Reply to 2 Other Threads', - send_introduction_message: 'Introduce Yourself in #introductions', -}; - function ActivationCard() { const { student } = useLoaderData(); - const formattedActivatedAt = dayjs(student.activatedAt).format( - 'MMMM D, YYYY' - ); - return ( - Activation + Activation ✅ - You are considered activated when you have completed the following - checklist. + You've completed {student.activationRequirementsCompleted.length}/6 + activation requirements. Once you hit all 6, you will be eligible to + claim your FREE swag pack! 👀 -
      - - - - - - -
    - - {student.activatedAt && ( - - You became activated on{' '} - {formattedActivatedAt}. - - )} + + + See Progress + +
    ); } -function ActivationChecklistItem({ - requirement, -}: PropsWithoutRef<{ requirement: ActivationRequirement }>) { - const { student } = useLoaderData(); - - const complete = - student.activationRequirementsCompleted.includes(requirement); - - return ( -
  • - {complete ? : } - {ActivationRequirementTitle[requirement]} -
  • - ); -} - function MessagesSentCard() { const { messagesSentCount } = useLoaderData(); diff --git a/apps/member-profile/app/routes/_profile.profile.integrations.tsx b/apps/member-profile/app/routes/_profile.profile.integrations.tsx index 66ceedd3..17294a3a 100644 --- a/apps/member-profile/app/routes/_profile.profile.integrations.tsx +++ b/apps/member-profile/app/routes/_profile.profile.integrations.tsx @@ -3,7 +3,7 @@ import { useLoaderData } from '@remix-run/react'; import { ExternalLink } from 'react-feather'; import { Pill, Text } from '@oyster/ui'; -import { iife } from '@oyster/utils'; +import { run } from '@oyster/utils'; import { ProfileHeader, @@ -23,7 +23,7 @@ export async function loader({ request }: LoaderFunctionArgs) { .select(['githubId']) .executeTakeFirstOrThrow(); - const githubOauthUri = iife(() => { + const githubOauthUri = run(() => { const url = new URL('https://github.com/login/oauth/authorize'); url.searchParams.set('client_id', ENV.GITHUB_OAUTH_CLIENT_ID || ''); diff --git a/apps/member-profile/app/routes/_profile.recap.$date.reviews.tsx b/apps/member-profile/app/routes/_profile.recap.$date.reviews.tsx index a3d8bdc3..589e4214 100644 --- a/apps/member-profile/app/routes/_profile.recap.$date.reviews.tsx +++ b/apps/member-profile/app/routes/_profile.recap.$date.reviews.tsx @@ -7,15 +7,16 @@ import { listCompanyReviews } from '@oyster/core/employment.server'; import { type EmploymentType, type LocationType } from '@/member-profile.ui'; import { getDateRange, Recap } from '@/routes/_profile.recap.$date'; import { CompanyReview } from '@/shared/components/company-review'; -import { ensureUserAuthenticated } from '@/shared/session.server'; +import { ensureUserAuthenticated, user } from '@/shared/session.server'; export async function loader({ params, request }: LoaderFunctionArgs) { - await ensureUserAuthenticated(request); + const session = await ensureUserAuthenticated(request); const { endOfWeek, startOfWeek } = getDateRange(params.date); const _reviews = await listCompanyReviews({ includeCompanies: true, + memberId: user(session), select: [ 'companyReviews.createdAt', 'companyReviews.id', @@ -79,6 +80,7 @@ export default function RecapReviews() { return ( ); })} diff --git a/apps/member-profile/app/routes/_profile.recap.$date.tsx b/apps/member-profile/app/routes/_profile.recap.$date.tsx index 40df6e04..48bcfa6b 100644 --- a/apps/member-profile/app/routes/_profile.recap.$date.tsx +++ b/apps/member-profile/app/routes/_profile.recap.$date.tsx @@ -15,7 +15,7 @@ import { track } from '@oyster/core/mixpanel'; import { listResources } from '@oyster/core/resources.server'; import { listSlackMessages } from '@oyster/core/slack.server'; import { Divider, Text } from '@oyster/ui'; -import { iife } from '@oyster/utils'; +import { run } from '@oyster/utils'; import { listMembersInDirectory } from '@/member-profile.server'; import { NavigationItem } from '@/shared/components/navigation'; @@ -79,6 +79,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { }), listCompanyReviews({ + memberId: '', select: [], where: { postedAfter: startOfWeek, @@ -100,7 +101,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { }), ]); - const dateRange = iife(() => { + const dateRange = run(() => { const format = 'dddd, MMMM D, YYYY'; const startRange = dayObject.startOf('week').format(format); diff --git a/apps/member-profile/app/routes/_profile.resume-books.$id.tsx b/apps/member-profile/app/routes/_profile.resume-books.$id.tsx index bd970e40..82729482 100644 --- a/apps/member-profile/app/routes/_profile.resume-books.$id.tsx +++ b/apps/member-profile/app/routes/_profile.resume-books.$id.tsx @@ -55,7 +55,7 @@ import { TooltipText, TooltipTrigger, } from '@oyster/ui/tooltip'; -import { iife } from '@oyster/utils'; +import { run } from '@oyster/utils'; import { type DegreeType, FORMATTED_DEGREEE_TYPE } from '@/member-profile.ui'; import { HometownField } from '@/shared/components/profile.personal'; @@ -150,7 +150,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { .tz(timezone) .format('dddd, MMMM DD, YYYY @ h:mm A (z)'), - status: iife(() => { + status: run(() => { const now = dayjs(); if (now.isBefore(_resumeBook.startDate)) { @@ -392,7 +392,7 @@ function ResumeBookForm() { encType="multipart/form-data" > { + description={run(() => { const emailLink = ( { return ( @@ -165,6 +171,11 @@ export const CompanyReview = ({ +
    ); }; @@ -248,7 +259,7 @@ function CompanyReviewText({ text }: Pick) { + + ); +} + CompanyReview.List = function List({ children }: PropsWithChildren) { return
      {children}
    ; }; diff --git a/apps/member-profile/app/shared/components/resource-form.tsx b/apps/member-profile/app/shared/components/resource-form.tsx index 31d68ce0..1c428d8d 100644 --- a/apps/member-profile/app/shared/components/resource-form.tsx +++ b/apps/member-profile/app/shared/components/resource-form.tsx @@ -156,7 +156,6 @@ export function ResourceTagsField({ const tags = listFetcher.data?.tags || []; function reset() { - setSearch(''); setNewTagId(id()); } diff --git a/apps/member-profile/app/shared/constants.ts b/apps/member-profile/app/shared/constants.ts index 491e0561..a525fbac 100644 --- a/apps/member-profile/app/shared/constants.ts +++ b/apps/member-profile/app/shared/constants.ts @@ -19,6 +19,7 @@ const ROUTES = [ '/events/upcoming/:id/register', '/events/upcoming/:id/registrations', '/home', + '/home/activation', '/home/claim-swag-pack', '/home/claim-swag-pack/confirmation', '/login', diff --git a/config/eslint/base.js b/config/eslint/base.js index 77330a4b..4dd20455 100644 --- a/config/eslint/base.js +++ b/config/eslint/base.js @@ -23,6 +23,7 @@ module.exports = { plugins: ['@typescript-eslint', 'import'], root: true, rules: { + '@typescript-eslint/ban-types': ['off'], '@typescript-eslint/consistent-type-exports': [ 'error', { fixMixedExportsWithInlineTypeSpecifier: true }, diff --git a/package.json b/package.json index 09c93b0d..9df468cf 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "env:setup": "yarn workspace @oyster/scripts env:setup", "lint": "turbo run lint --cache-dir=.turbo", "lint:fix": "turbo run lint:fix --cache-dir=.turbo", - "prepare": "husky install", "prisma:setup": "yarn workspace @oyster/scripts prisma:setup", "prisma:studio": "yarn workspace @oyster/db prisma:studio", "start": "turbo run start --cache-dir=.turbo", @@ -30,8 +29,6 @@ "type-check": "turbo run type-check --cache-dir=.turbo" }, "devDependencies": { - "husky": "^8.0.0", - "lint-staged": "^15.2.2", "prettier": "^3.2.5", "turbo": "latest", "typescript": "^5.4.2" diff --git a/packages/core/src/admin-dashboard.server.ts b/packages/core/src/admin-dashboard.server.ts index 29ee5c21..44a61028 100644 --- a/packages/core/src/admin-dashboard.server.ts +++ b/packages/core/src/admin-dashboard.server.ts @@ -1,4 +1,4 @@ -export { QueueFromName } from './infrastructure/bull/bull'; +export { listQueueNames, getQueue } from './infrastructure/bull/bull'; export { job } from './infrastructure/bull/use-cases/job'; export { getGoogleAuthUri } from './modules/authentication/shared/oauth.utils'; export { sendOneTimeCode } from './modules/authentication/use-cases/send-one-time-code'; diff --git a/packages/core/src/admin-dashboard.ui.ts b/packages/core/src/admin-dashboard.ui.ts index 4ca23109..8770981e 100644 --- a/packages/core/src/admin-dashboard.ui.ts +++ b/packages/core/src/admin-dashboard.ui.ts @@ -1,4 +1,3 @@ -export { BullQueue } from './infrastructure/bull/bull.types'; export { SendOneTimeCodeInput, VerifyOneTimeCodeInput, diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index c69c303e..f6c09cda 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -7,27 +7,46 @@ dayjs.extend(customParseFormat); dayjs.extend(utc); dayjs.extend(timezone); -// This is only meant to be imported by the `api` application. +import { airtableWorker } from './modules/airtable/airtable.core'; +import { applicationWorker } from './modules/application/application.core'; +import { oneTimeCodeWorker } from './modules/authentication/one-time-code.worker'; +import { eventWorker } from './modules/event/event.worker'; +import { feedWorker } from './modules/feed/feed'; +import { gamificationWorker } from './modules/gamification/gamification.core'; +import { emailMarketingWorker } from './modules/mailchimp/email-marketing.worker'; +import { memberEmailWorker } from './modules/member/member-email.worker'; +import { memberWorker } from './modules/member/member.worker'; +import { profileWorker } from './modules/member/profile.worker'; +import { notificationWorker } from './modules/notification/notification.worker'; +import { onboardingSessionWorker } from './modules/onboarding-session/onboarding-session.worker'; +import { slackWorker } from './modules/slack/slack.worker'; +import { swagPackWorker } from './modules/swag-pack/swag-pack.worker'; export { job } from './infrastructure/bull/use-cases/job'; -export { airtableWorker } from './modules/airtable/airtable.core'; -export { applicationWorker } from './modules/application/application.core'; export { OAuthCodeState } from './modules/authentication/authentication.types'; -export { oneTimeCodeWorker } from './modules/authentication/one-time-code.worker'; export { loginWithOAuth } from './modules/authentication/use-cases/login-with-oauth'; -export { educationWorker } from './modules/education/education.worker'; -export { workExperienceWorker } from './modules/employment/employment.worker'; -export { eventWorker } from './modules/event/event.worker'; -export { feedWorker } from './modules/feed/feed.core'; -export { gamificationWorker } from './modules/gamification/gamification.core'; export { saveGoogleDriveCredentials } from './modules/google-drive'; -export { emailMarketingWorker } from './modules/mailchimp/email-marketing.worker'; -export { memberEmailWorker } from './modules/member/member-email.worker'; -export { memberWorker } from './modules/member/member.worker'; -export { profileWorker } from './modules/member/profile.worker'; -export { notificationWorker } from './modules/notification/notification.worker'; -export { onboardingSessionWorker } from './modules/onboarding-session/onboarding-session.worker'; -export { slackWorker } from './modules/slack/slack.worker'; -export { surveyWorker } from './modules/survey/survey.worker'; -export { swagPackWorker } from './modules/swag-pack/swag-pack.worker'; export { Environment } from './shared/types'; + +/** + * Starts all Bull workers for various modules in the application. + * + * Each worker is responsible for processing jobs in its respective queue, + * allowing for distributed and asynchronous task execution. + */ +export function startBullWorkers(): void { + airtableWorker.run(); + applicationWorker.run(); + emailMarketingWorker.run(); + eventWorker.run(); + feedWorker.run(); + gamificationWorker.run(); + memberWorker.run(); + memberEmailWorker.run(); + notificationWorker.run(); + onboardingSessionWorker.run(); + oneTimeCodeWorker.run(); + profileWorker.run(); + slackWorker.run(); + swagPackWorker.run(); +} diff --git a/packages/core/src/infrastructure/bull/bull.ts b/packages/core/src/infrastructure/bull/bull.ts index 32daf44a..cecbcc93 100644 --- a/packages/core/src/infrastructure/bull/bull.ts +++ b/packages/core/src/infrastructure/bull/bull.ts @@ -1,27 +1,57 @@ import { Queue } from 'bullmq'; import { Redis } from 'ioredis'; -import { iife } from '@oyster/utils'; - +import { redis } from '@/infrastructure/redis'; import { ENV } from '@/shared/env'; -import { BullQueue } from './bull.types'; -export const QueueFromName = iife(() => { - const result = {} as Record; +// Instead of instantiating a new queue at the top-level which would produce +// a side-effect, we'll use a global variable to store the queue instances which +// will be created lazily. +const _queues: Record = {}; + +/** + * Returns a Bull queue instance for the given name. + * + * This function uses a lazy initialization approach to create queue instances + * only when they are first requested. Subsequent calls with the same name + * will return the existing queue instance. + * + * @param name - The name of the queue to retrieve/create. + * @returns A Bull queue instance for the specified name. + */ +export function getQueue(name: string) { + if (!_queues[name]) { + const connection = new Redis(ENV.REDIS_URL, { + maxRetriesPerRequest: null, + }); - Object.values(BullQueue).forEach((name) => { - result[name] = new Queue(name, { - connection: new Redis(ENV.REDIS_URL as string, { - maxRetriesPerRequest: null, - }), + _queues[name] = new Queue(name, { + connection, defaultJobOptions: { - attempts: 5, + attempts: 3, backoff: { delay: 5000, type: 'exponential' }, - removeOnComplete: { age: 60 * 60 * 24 * 1 }, - removeOnFail: { age: 60 * 60 * 24 * 7 }, + removeOnComplete: { age: 60 * 60 * 24 * 1, count: 100 }, + removeOnFail: { age: 60 * 60 * 24 * 7, count: 1000 }, }, }); - }); + } + + return _queues[name]; +} + +/** + * Lists all the queues currently present in Redis. + * + * This function retrieves all keys in Redis that match the pattern + * `bull:*:meta`, which corresponds to Bull queue metadata. It then extracts + * and sorts the queue names from these keys. + * + * @returns An array of sorted queue names. + */ +export async function listQueueNames() { + const keys = await redis.keys('bull:*:meta'); + + const names = keys.map((key) => key.split(':')[1]).sort(); - return result; -}); + return names; +} diff --git a/packages/core/src/infrastructure/bull/bull.types.ts b/packages/core/src/infrastructure/bull/bull.types.ts index 3dbcf972..e2ac2e52 100644 --- a/packages/core/src/infrastructure/bull/bull.types.ts +++ b/packages/core/src/infrastructure/bull/bull.types.ts @@ -13,8 +13,6 @@ import { } from '@oyster/types'; import { OneTimeCode } from '@/modules/authentication/authentication.types'; -import { Education } from '@/modules/education/education.types'; -import { WorkExperience } from '@/modules/employment/employment.types'; import { ActivityType, CompletedActivity, @@ -30,7 +28,6 @@ import { Survey } from '@/modules/survey/survey.types'; export const BullQueue = { AIRTABLE: 'airtable', APPLICATION: 'application', - EDUCATION_HISTORY: 'education_history', EMAIL_MARKETING: 'email_marketing', EVENT: 'event', FEED: 'feed', @@ -42,9 +39,7 @@ export const BullQueue = { PROFILE: 'profile', SLACK: 'slack', STUDENT: 'student', - SURVEY: 'survey', SWAG_PACK: 'swag_pack', - WORK_HISTORY: 'work_history', } as const; export type BullQueue = ExtractValue; @@ -105,23 +100,6 @@ export const ApplicationBullJob = z.discriminatedUnion('name', [ }), ]); -export const EducationHistoryBullJob = z.discriminatedUnion('name', [ - z.object({ - name: z.literal('education.added'), - data: z.object({ - educationId: Education.shape.id, - studentId: Student.shape.id, - }), - }), - z.object({ - name: z.literal('education.deleted'), - data: z.object({ - educationId: Education.shape.id, - studentId: Student.shape.id, - }), - }), -]); - export const EmailMarketingBullJob = z.discriminatedUnion('name', [ z.object({ name: z.literal('email_marketing.opened'), @@ -579,16 +557,6 @@ export const StudentBullJob = z.discriminatedUnion('name', [ }), ]); -export const SurveyBullJob = z.discriminatedUnion('name', [ - z.object({ - name: z.literal('survey.responded'), - data: z.object({ - studentId: Student.shape.id, - surveyId: Survey.shape.id, - }), - }), -]); - export const SwagPackBullJob = z.discriminatedUnion('name', [ z.object({ name: z.literal('swag_pack.inventory.notify'), @@ -596,29 +564,11 @@ export const SwagPackBullJob = z.discriminatedUnion('name', [ }), ]); -export const WorkHistoryBullJob = z.discriminatedUnion('name', [ - z.object({ - name: z.literal('work_experience.added'), - data: z.object({ - studentId: Student.shape.id, - workExperienceId: WorkExperience.shape.id, - }), - }), - z.object({ - name: z.literal('work_experience.deleted'), - data: z.object({ - studentId: Student.shape.id, - workExperienceId: WorkExperience.shape.id, - }), - }), -]); - // Combination export const BullJob = z.union([ AirtableBullJob, ApplicationBullJob, - EducationHistoryBullJob, EmailMarketingBullJob, EventBullJob, FeedBullJob, @@ -630,9 +580,7 @@ export const BullJob = z.union([ ProfileBullJob, SlackBullJob, StudentBullJob, - SurveyBullJob, SwagPackBullJob, - WorkHistoryBullJob, ]); // Types diff --git a/packages/core/src/infrastructure/bull/use-cases/job.ts b/packages/core/src/infrastructure/bull/use-cases/job.ts index 0549a17e..a0c98fd6 100644 --- a/packages/core/src/infrastructure/bull/use-cases/job.ts +++ b/packages/core/src/infrastructure/bull/use-cases/job.ts @@ -1,14 +1,14 @@ import { type JobsOptions } from 'bullmq'; +import { getQueue } from '@/infrastructure/bull/bull'; +import { BullJob, type GetBullJobData } from '@/infrastructure/bull/bull.types'; import { reportException } from '@/modules/sentry/use-cases/report-exception'; -import { QueueFromName } from '../bull'; -import { BullJob, type BullQueue, type GetBullJobData } from '../bull.types'; export function job( name: JobName, data: GetBullJobData, options?: JobsOptions -) { +): void { const result = BullJob.safeParse({ data, name, @@ -22,73 +22,8 @@ export function job( const job = result.data; - const queueName = QueueNameFromJobName[job.name]; - const queue = QueueFromName[queueName]; + const queueName = job.name.split('.')[0]; + const queue = getQueue(queueName); queue.add(job.name, job.data, options).catch((e) => reportException(e)); } - -const QueueNameFromJobName: Record = { - 'airtable.record.create': 'airtable', - 'airtable.record.create.member': 'airtable', - 'airtable.record.delete': 'airtable', - 'airtable.record.update': 'airtable', - 'airtable.record.update.bulk': 'airtable', - 'application.review': 'application', - 'education.added': 'education_history', - 'education.deleted': 'education_history', - 'email_marketing.opened': 'email_marketing', - 'email_marketing.remove': 'email_marketing', - 'email_marketing.sync': 'email_marketing', - 'email_marketing.sync.daily': 'email_marketing', - 'email_marketing.sync.hourly': 'email_marketing', - 'email_marketing.sync.monthly': 'email_marketing', - 'email_marketing.sync.weekly': 'email_marketing', - 'email_marketing.sync.yearly': 'email_marketing', - 'event.attended': 'event', - 'event.recent.sync': 'event', - 'event.register': 'event', - 'event.registered': 'event', - 'event.sync': 'event', - 'feed.slack.recurring': 'feed', - 'gamification.activity.completed': 'gamification', - 'gamification.activity.completed.undo': 'gamification', - 'member_email.added': 'member_email', - 'member_email.primary.changed': 'member_email', - 'notification.email.send': 'notification', - 'notification.slack.send': 'notification', - 'onboarding_session.attended': 'onboarding_session', - 'one_time_code.expire': 'one_time_code', - 'profile.views.notification.monthly': 'profile', - 'slack.birthdates.update': 'slack', - 'slack.channel.archive': 'slack', - 'slack.channel.create': 'slack', - 'slack.channel.delete': 'slack', - 'slack.channel.rename': 'slack', - 'slack.channel.unarchive': 'slack', - 'slack.deactivate': 'slack', - 'slack.invite': 'slack', - 'slack.invited': 'slack', - 'slack.joined': 'slack', - 'slack.message.add': 'slack', - 'slack.message.added': 'slack', - 'slack.message.change': 'slack', - 'slack.message.delete': 'slack', - 'slack.profile_picture.changed': 'slack', - 'slack.reaction.add': 'slack', - 'slack.reaction.remove': 'slack', - 'student.activated': 'student', - 'student.activation_requirement_completed': 'student', - 'student.birthdate.daily': 'student', - 'student.created': 'student', - 'student.engagement.backfill': 'student', - 'student.points.recurring': 'student', - 'student.profile.viewed': 'student', - 'student.removed': 'student', - 'student.statuses.backfill': 'student', - 'student.statuses.new': 'student', - 'survey.responded': 'survey', - 'swag_pack.inventory.notify': 'swag_pack', - 'work_experience.added': 'work_history', - 'work_experience.deleted': 'work_history', -}; diff --git a/packages/core/src/infrastructure/bull/use-cases/register-worker.ts b/packages/core/src/infrastructure/bull/use-cases/register-worker.ts index b17915e1..5d3ac4c2 100644 --- a/packages/core/src/infrastructure/bull/use-cases/register-worker.ts +++ b/packages/core/src/infrastructure/bull/use-cases/register-worker.ts @@ -1,12 +1,25 @@ -import { type Job, QueueEvents, Worker, type WorkerOptions } from 'bullmq'; +import { Worker, type WorkerOptions } from 'bullmq'; import { Redis } from 'ioredis'; import { type z, type ZodType } from 'zod'; import { reportException } from '@/modules/sentry/use-cases/report-exception'; import { ENV } from '@/shared/env'; -import { ErrorWithContext, ZodParseError } from '@/shared/errors'; +import { ZodParseError } from '@/shared/errors'; import { type BullQueue } from '../bull.types'; +/** + * Registers a worker for processing jobs in a Bull queue. + * + * This validates the incoming job data against the provided Zod schema before + * processing. If validation fails, it throws a `ZodParseError`. Any errors are + * reported to Sentry. + * + * @param name - The name of the queue to process. + * @param schema - Zod schema for validating the job data. + * @param processor - The function to process each job. + * @param options - Optional configuration for the worker. + * @returns A `Worker` instance. + */ export function registerWorker( name: BullQueue, schema: Schema, @@ -20,49 +33,38 @@ export function registerWorker( options = { autorun: false, connection: redis, - removeOnComplete: { age: 60 * 60 * 24 * 1 }, - removeOnFail: { age: 60 * 60 * 24 * 7 }, + removeOnComplete: { age: 60 * 60 * 24 * 1, count: 100 }, + removeOnFail: { age: 60 * 60 * 24 * 7, count: 1000 }, ...options, }; const worker = new Worker( name, - async (input) => { - const job = validateJob(schema, input); + async function handle(input) { + const result = schema.safeParse({ + data: input.data, + name: input.name, + }); + + if (!result.success) { + throw new ZodParseError(result.error); + } + + const job = result.data; return processor(job); }, options ); - const queueEvents = new QueueEvents(name, { - connection: redis, - }); - - queueEvents.on('failed', ({ failedReason, jobId }) => { - reportException( - new BullJobFailedError(failedReason).withContext({ - jobId, - }) - ); + worker.on('failed', (job, error) => { + reportException(error, { + jobData: job?.data, + jobId: job?.id, + jobName: job?.name, + queueName: job?.queueName, + }); }); return worker; } - -function validateJob(schema: Schema, job: Job) { - const result = schema.safeParse({ - data: job.data, - name: job.name, - }); - - if (!result.success) { - throw new ZodParseError(result.error); - } - - const data = result.data as z.infer; - - return data; -} - -class BullJobFailedError extends ErrorWithContext {} diff --git a/packages/core/src/modules/admin/admin.core.ts b/packages/core/src/modules/admin/admin.core.ts index 7b64dc4f..fe598b53 100644 --- a/packages/core/src/modules/admin/admin.core.ts +++ b/packages/core/src/modules/admin/admin.core.ts @@ -2,8 +2,12 @@ import { type DB, db } from '@oyster/db'; import { id } from '@oyster/utils'; import { type SelectExpression } from '@/shared/types'; -import { fail, success } from '@/shared/utils/core.utils'; -import { type AddAdminInput, AdminRole } from './admin.types'; +import { fail, type Result, success } from '@/shared/utils/core.utils'; +import { + type AddAdminInput, + AdminRole, + type RemoveAdminInput, +} from './admin.types'; // Types @@ -65,6 +69,7 @@ export async function listAdmins({ const admins = await db .selectFrom('admins') .select(select) + .orderBy('deletedAt', 'desc') // Show active admins first... .orderBy('createdAt', 'desc') .execute(); @@ -156,6 +161,63 @@ export async function addAdmin({ return success({ id: adminId }); } +/** + * Removes a ColorStack admin. Note that the actor must have the required role + * to remove an admin (ie: admin cannot remove an owner). + * + * This will revoke the user access to the Admin Dashboard. Note that this + * only SOFT deletes the record (ie: sets the `deletedAt` timestamp). + */ +export async function removeAdmin({ + actor, + id, +}: RemoveAdminInput): Promise { + if (actor === id) { + return fail({ + code: 400, + error: 'You cannot remove yourself, silly!', + }); + } + + const [actingAdmin, adminToRemove] = await Promise.all([ + getAdmin({ + select: ['admins.role'], + where: { id: actor }, + }), + getAdmin({ + select: ['admins.role'], + where: { id }, + }), + ]); + + if (!actingAdmin || !adminToRemove) { + return fail({ + code: 404, + error: 'The admin does not exist.', + }); + } + + const hasPermission = doesAdminHavePermission({ + minimumRole: adminToRemove.role as AdminRole, + role: actingAdmin.role as AdminRole, + }); + + if (!hasPermission) { + return fail({ + code: 403, + error: 'You do not have permission to remove an admin with this role.', + }); + } + + await db + .updateTable('admins') + .set({ deletedAt: new Date() }) + .where('id', '=', id) + .executeTakeFirstOrThrow(); + + return success({}); +} + // Helpers type DoesAdminHavePermissionInput = { diff --git a/packages/core/src/modules/admin/admin.types.ts b/packages/core/src/modules/admin/admin.types.ts index 057291be..534ea5b3 100644 --- a/packages/core/src/modules/admin/admin.types.ts +++ b/packages/core/src/modules/admin/admin.types.ts @@ -22,4 +22,10 @@ export const AddAdminInput = z.object({ role: z.nativeEnum(AdminRole), }); +export const RemoveAdminInput = z.object({ + actor: z.string().trim().min(1), + id: z.string().trim().min(1), +}); + export type AddAdminInput = z.infer; +export type RemoveAdminInput = z.infer; diff --git a/packages/core/src/modules/admin/admin.ui.tsx b/packages/core/src/modules/admin/admin.ui.tsx index fbe71c7f..d625519c 100644 --- a/packages/core/src/modules/admin/admin.ui.tsx +++ b/packages/core/src/modules/admin/admin.ui.tsx @@ -1,9 +1,12 @@ -import { Form as RemixForm } from '@remix-run/react'; +import { generatePath, Link, Form as RemixForm } from '@remix-run/react'; +import { useState } from 'react'; +import { Trash } from 'react-feather'; import { match } from 'ts-pattern'; import { type DB } from '@oyster/db'; import { Button, + Dropdown, Form, Input, Pill, @@ -78,8 +81,11 @@ export function AdminForm({ error, errors }: AdminFormProps) { type AdminInTable = Pick< DB['admins'], - 'email' | 'firstName' | 'lastName' | 'role' ->; + 'email' | 'firstName' | 'id' | 'lastName' | 'role' +> & { + canRemove: boolean; + isDeleted: boolean; +}; type AdminTableProps = { admins: AdminInTable[]; @@ -99,7 +105,7 @@ export function AdminTable({ admins }: AdminTableProps) { }, { displayName: 'Role', - size: '200', + size: '160', render: (admin) => { return match(admin.role as AdminRole) .with('admin', () => { @@ -109,14 +115,66 @@ export function AdminTable({ admins }: AdminTableProps) { return Ambassador; }) .with('owner', () => { - return Owner; + return Owner; }) .exhaustive(); }, }, + { + displayName: 'Status', + size: '160', + render: (admin) => { + return admin.isDeleted ? ( + Archived + ) : ( + Active + ); + }, + }, ]; return ( -
    +
    { + if (!row.canRemove) { + return null; + } + + return ; + }} + /> + ); +} + +function AdminDropdown({ id }: AdminInTable) { + const [open, setOpen] = useState(false); + + function onClose() { + setOpen(false); + } + + function onOpen() { + setOpen(true); + } + + return ( + + {open && ( + + + + + Remove Admin + + + + + )} + + + ); } diff --git a/packages/core/src/modules/application/application.core.ts b/packages/core/src/modules/application/application.core.ts index 1b5bf397..41eec276 100644 --- a/packages/core/src/modules/application/application.core.ts +++ b/packages/core/src/modules/application/application.core.ts @@ -4,7 +4,7 @@ import { match } from 'ts-pattern'; import { type DB, db } from '@oyster/db'; import { type Application, OtherDemographic } from '@oyster/types'; -import { id, iife } from '@oyster/utils'; +import { id, run } from '@oyster/utils'; import { ApplicationBullJob, @@ -430,7 +430,7 @@ function queueRejectionEmail({ }, { delay: automated - ? iife(() => { + ? run(() => { const now = dayjs().tz('America/Los_Angeles'); const tomorrowMorning = now.add(1, 'day').hour(9); const delay = tomorrowMorning.diff(now); diff --git a/packages/core/src/modules/application/application.ui.tsx b/packages/core/src/modules/application/application.ui.tsx index 2ef54190..f1f8309a 100644 --- a/packages/core/src/modules/application/application.ui.tsx +++ b/packages/core/src/modules/application/application.ui.tsx @@ -22,7 +22,7 @@ import { Textarea, type TextProps, } from '@oyster/ui'; -import { iife, toTitleCase } from '@oyster/utils'; +import { run, toTitleCase } from '@oyster/utils'; import { type EducationLevel, @@ -241,7 +241,7 @@ Application.GoalsField = function GoalsField({ ); }; -const GRADUATION_YEARS = iife(() => { +const GRADUATION_YEARS = run(() => { const currentYear = new Date().getFullYear(); const years: number[] = []; diff --git a/packages/core/src/modules/education/education.core.ts b/packages/core/src/modules/education/education.core.ts index 1a5b8b3d..40781b6a 100644 --- a/packages/core/src/modules/education/education.core.ts +++ b/packages/core/src/modules/education/education.core.ts @@ -85,6 +85,7 @@ export async function updateSchool({ addressZip, id, name, + tags, }: UpdateSchoolInput) { await db.transaction().execute(async (trx) => { await trx @@ -94,6 +95,7 @@ export async function updateSchool({ addressState, addressZip, name, + tags, }) .where('id', '=', id) .execute(); diff --git a/packages/core/src/modules/education/education.types.ts b/packages/core/src/modules/education/education.types.ts index 008de865..d0b0336b 100644 --- a/packages/core/src/modules/education/education.types.ts +++ b/packages/core/src/modules/education/education.types.ts @@ -46,6 +46,11 @@ export const FORMATTED_EDUCATION_LEVEL: Record = { undergraduate: 'Undergraduate', }; +export const SchoolTag = { + HBCU: 'hbcu', + HSI: 'hsi', +} as const; + // Schemas export const Education = Entity.extend({ @@ -66,6 +71,7 @@ export const School = Entity.extend({ addressState: Address.shape.state, addressZip: Address.shape.zip, name: z.string().min(1), + tags: z.array(z.nativeEnum(SchoolTag)).optional(), }); // Use Cases @@ -94,6 +100,7 @@ export const UpdateSchoolInput = School.pick({ addressZip: true, id: true, name: true, + tags: true, }); // Types diff --git a/packages/core/src/modules/education/education.ui.tsx b/packages/core/src/modules/education/education.ui.tsx index 7191296f..a5fd8beb 100644 --- a/packages/core/src/modules/education/education.ui.tsx +++ b/packages/core/src/modules/education/education.ui.tsx @@ -12,11 +12,12 @@ import { Form, Input, type InputProps, + Select, useDelayedValue, } from '@oyster/ui'; import { toEscapedString, toTitleCase } from '@oyster/utils'; -import { School } from '@/modules/education/education.types'; +import { School, SchoolTag } from '@/modules/education/education.types'; // School Form @@ -84,6 +85,39 @@ export function SchoolStateField({ ); } +const SCHOOL_TAG_OPTIONS = [ + { label: 'HBCU', value: SchoolTag.HBCU }, + { label: 'HSI', value: SchoolTag.HSI }, +]; + +export function SchoolTagsField({ + defaultValue, + error, +}: Omit, 'name'>) { + return ( + + + + ); +} + export function SchoolZipField({ defaultValue, error, diff --git a/packages/core/src/modules/education/education.worker.ts b/packages/core/src/modules/education/education.worker.ts deleted file mode 100644 index 9b919fd7..00000000 --- a/packages/core/src/modules/education/education.worker.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { match } from 'ts-pattern'; - -import { EducationHistoryBullJob } from '@/infrastructure/bull/bull.types'; -import { registerWorker } from '@/infrastructure/bull/use-cases/register-worker'; -import { onEducationAdded } from './events/education-added'; -import { onEducationDeleted } from './events/education-deleted'; - -export const educationWorker = registerWorker( - 'education_history', - EducationHistoryBullJob, - async (result) => { - return match(result) - .with({ name: 'education.added' }, ({ data }) => { - return onEducationAdded(data); - }) - .with({ name: 'education.deleted' }, ({ data }) => { - return onEducationDeleted(data); - }) - .exhaustive(); - } -); diff --git a/packages/core/src/modules/education/events/education-added.ts b/packages/core/src/modules/education/events/education-added.ts deleted file mode 100644 index ae0aa231..00000000 --- a/packages/core/src/modules/education/events/education-added.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { job } from '@/infrastructure/bull/use-cases/job'; - -export async function onEducationAdded({ - educationId: _, - studentId, -}: GetBullJobData<'education.added'>) { - job('gamification.activity.completed', { - studentId, - type: 'update_education_history', - }); -} diff --git a/packages/core/src/modules/education/events/education-deleted.ts b/packages/core/src/modules/education/events/education-deleted.ts deleted file mode 100644 index c1948ad1..00000000 --- a/packages/core/src/modules/education/events/education-deleted.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { job } from '@/infrastructure/bull/use-cases/job'; - -export async function onEducationDeleted({ - educationId: _, - studentId, -}: GetBullJobData<'education.deleted'>) { - job('gamification.activity.completed.undo', { - studentId, - type: 'update_education_history', - }); -} diff --git a/packages/core/src/modules/education/use-cases/add-education.ts b/packages/core/src/modules/education/use-cases/add-education.ts index 9411b891..90de28d5 100644 --- a/packages/core/src/modules/education/use-cases/add-education.ts +++ b/packages/core/src/modules/education/use-cases/add-education.ts @@ -25,8 +25,8 @@ export async function addEducation(input: AddEducationInput) { checkMostRecentEducation(input.studentId); - job('education.added', { - educationId, + job('gamification.activity.completed', { studentId: input.studentId, + type: 'update_education_history', }); } diff --git a/packages/core/src/modules/education/use-cases/delete-education.ts b/packages/core/src/modules/education/use-cases/delete-education.ts index f25bcf73..c59227bf 100644 --- a/packages/core/src/modules/education/use-cases/delete-education.ts +++ b/packages/core/src/modules/education/use-cases/delete-education.ts @@ -15,8 +15,8 @@ export async function deleteEducation({ id, studentId }: DeleteEducationInput) { checkMostRecentEducation(studentId); - job('education.deleted', { - educationId: id, + job('gamification.activity.completed.undo', { studentId, + type: 'update_education_history', }); } diff --git a/packages/core/src/modules/employment/employment.types.ts b/packages/core/src/modules/employment/employment.types.ts index ed574d6d..6ef02020 100644 --- a/packages/core/src/modules/employment/employment.types.ts +++ b/packages/core/src/modules/employment/employment.types.ts @@ -190,6 +190,10 @@ export const UploadJobOfferInput = JobOffer.omit({ updatedAt: true, }); +export const UpvoteCompanyReviewInput = z.object({ + memberId: z.string().trim().min(1), +}); + export type AddCompanyReviewInput = z.infer; export type AddWorkExperienceInput = z.infer; export type DeleteWorkExperienceInput = z.infer< @@ -198,3 +202,4 @@ export type DeleteWorkExperienceInput = z.infer< export type EditCompanyReviewInput = z.infer; export type EditWorkExperienceInput = z.infer; export type UploadJobOfferInput = z.infer; +export type UpvoteCompanyReviewInput = z.infer; diff --git a/packages/core/src/modules/employment/employment.worker.ts b/packages/core/src/modules/employment/employment.worker.ts deleted file mode 100644 index 6bb6ad79..00000000 --- a/packages/core/src/modules/employment/employment.worker.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { match } from 'ts-pattern'; - -import { WorkHistoryBullJob } from '@/infrastructure/bull/bull.types'; -import { registerWorker } from '@/infrastructure/bull/use-cases/register-worker'; -import { onWorkExperienceAdded } from './events/work-experience-added'; -import { onWorkExperienceDeleted } from './events/work-experience-deleted'; - -export const workExperienceWorker = registerWorker( - 'work_history', - WorkHistoryBullJob, - async (job) => { - return match(job) - .with({ name: 'work_experience.added' }, ({ data }) => { - return onWorkExperienceAdded(data); - }) - .with({ name: 'work_experience.deleted' }, ({ data }) => { - return onWorkExperienceDeleted(data); - }) - .exhaustive(); - } -); diff --git a/packages/core/src/modules/employment/events/work-experience-added.ts b/packages/core/src/modules/employment/events/work-experience-added.ts deleted file mode 100644 index 1444378a..00000000 --- a/packages/core/src/modules/employment/events/work-experience-added.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { job } from '@/infrastructure/bull/use-cases/job'; - -export async function onWorkExperienceAdded({ - studentId, - workExperienceId: _, -}: GetBullJobData<'work_experience.added'>) { - job('gamification.activity.completed', { - studentId, - type: 'update_work_history', - }); -} diff --git a/packages/core/src/modules/employment/events/work-experience-deleted.ts b/packages/core/src/modules/employment/events/work-experience-deleted.ts deleted file mode 100644 index 88c998f0..00000000 --- a/packages/core/src/modules/employment/events/work-experience-deleted.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { job } from '@/infrastructure/bull/use-cases/job'; - -export async function onWorkExperienceDeleted({ - studentId, - workExperienceId: _, -}: GetBullJobData<'work_experience.deleted'>) { - job('gamification.activity.completed.undo', { - studentId, - type: 'update_work_history', - }); -} diff --git a/packages/core/src/modules/employment/index.server.ts b/packages/core/src/modules/employment/index.server.ts index f7b09b62..357983cb 100644 --- a/packages/core/src/modules/employment/index.server.ts +++ b/packages/core/src/modules/employment/index.server.ts @@ -5,3 +5,5 @@ export { listCompanyEmployees } from './queries/list-company-employees'; export { listCompanyReviews } from './queries/list-company-reviews'; export { addCompanyReview } from './use-cases/add-company-review'; export { editCompanyReview } from './use-cases/edit-company-review'; +export { undoUpvoteCompanyReview } from './use-cases/undo-upvote-company-review'; +export { upvoteCompanyReview } from './use-cases/upvote-company-review'; diff --git a/packages/core/src/modules/employment/queries/list-company-reviews.ts b/packages/core/src/modules/employment/queries/list-company-reviews.ts index 0397dd0a..41609f0f 100644 --- a/packages/core/src/modules/employment/queries/list-company-reviews.ts +++ b/packages/core/src/modules/employment/queries/list-company-reviews.ts @@ -6,6 +6,7 @@ import { type ListCompanyReviewsWhere } from '@/modules/employment/employment.ty type ListCompanyReviewsOptions = { includeCompanies?: boolean; + memberId: string; select: Selection[]; where: ListCompanyReviewsWhere; }; @@ -15,7 +16,12 @@ export async function listCompanyReviews< DB, 'companyReviews' | 'students' | 'workExperiences' >, ->({ includeCompanies, select, where }: ListCompanyReviewsOptions) { +>({ + includeCompanies, + memberId, + select, + where, +}: ListCompanyReviewsOptions) { const reviews = await db .selectFrom('companyReviews') .leftJoin( @@ -24,7 +30,34 @@ export async function listCompanyReviews< 'companyReviews.workExperienceId' ) .leftJoin('students', 'students.id', 'workExperiences.studentId') - .select(select) + .select([ + ...select, + (eb) => { + return eb + .selectFrom('companyReviewUpvotes') + .select(eb.fn.countAll().as('count')) + .whereRef( + 'companyReviewUpvotes.companyReviewId', + '=', + 'companyReviews.id' + ) + .as('upvotes'); + }, + (eb) => { + return eb + .exists((eb) => { + return eb + .selectFrom('companyReviewUpvotes') + .whereRef( + 'companyReviewUpvotes.companyReviewId', + '=', + 'companyReviews.id' + ) + .where('companyReviewUpvotes.studentId', '=', memberId); + }) + .as('upvoted'); + }, + ]) .$if(!!includeCompanies, (qb) => { return qb .leftJoin('companies', 'companies.id', 'workExperiences.companyId') diff --git a/packages/core/src/modules/employment/use-cases/add-work-experience.ts b/packages/core/src/modules/employment/use-cases/add-work-experience.ts index 6b553400..7bb95720 100644 --- a/packages/core/src/modules/employment/use-cases/add-work-experience.ts +++ b/packages/core/src/modules/employment/use-cases/add-work-experience.ts @@ -48,8 +48,8 @@ export async function addWorkExperience({ .execute(); }); - job('work_experience.added', { + job('gamification.activity.completed', { studentId, - workExperienceId, + type: 'update_work_history', }); } diff --git a/packages/core/src/modules/employment/use-cases/delete-work-experience.ts b/packages/core/src/modules/employment/use-cases/delete-work-experience.ts index c616ea97..fabfe03e 100644 --- a/packages/core/src/modules/employment/use-cases/delete-work-experience.ts +++ b/packages/core/src/modules/employment/use-cases/delete-work-experience.ts @@ -19,8 +19,8 @@ export async function deleteWorkExperience({ .where('studentId', '=', studentId) .execute(); - job('work_experience.deleted', { + job('gamification.activity.completed.undo', { studentId, - workExperienceId: id, + type: 'update_work_history', }); } diff --git a/packages/core/src/modules/employment/use-cases/undo-upvote-company-review.ts b/packages/core/src/modules/employment/use-cases/undo-upvote-company-review.ts new file mode 100644 index 00000000..47c318df --- /dev/null +++ b/packages/core/src/modules/employment/use-cases/undo-upvote-company-review.ts @@ -0,0 +1,18 @@ +import { db } from '@oyster/db'; + +import { type UpvoteCompanyReviewInput } from '@/modules/employment/employment.types'; + +export async function undoUpvoteCompanyReview( + id: string, + input: UpvoteCompanyReviewInput +) { + const result = await db.transaction().execute(async (trx) => { + await trx + .deleteFrom('companyReviewUpvotes') + .where('companyReviewId', '=', id) + .where('studentId', '=', input.memberId) + .execute(); + }); + + return result; +} diff --git a/packages/core/src/modules/employment/use-cases/upvote-company-review.ts b/packages/core/src/modules/employment/use-cases/upvote-company-review.ts new file mode 100644 index 00000000..7bc308d4 --- /dev/null +++ b/packages/core/src/modules/employment/use-cases/upvote-company-review.ts @@ -0,0 +1,21 @@ +import { db } from '@oyster/db'; + +import { type UpvoteCompanyReviewInput } from '@/modules/employment/employment.types'; + +export async function upvoteCompanyReview( + id: string, + input: UpvoteCompanyReviewInput +) { + const result = await db.transaction().execute(async (trx) => { + await trx + .insertInto('companyReviewUpvotes') + .values({ + companyReviewId: id, + studentId: input.memberId, + }) + .onConflict((oc) => oc.doNothing()) + .execute(); + }); + + return result; +} diff --git a/packages/core/src/modules/feed/feed.core.ts b/packages/core/src/modules/feed/feed.core.ts deleted file mode 100644 index c8efc7d6..00000000 --- a/packages/core/src/modules/feed/feed.core.ts +++ /dev/null @@ -1,106 +0,0 @@ -import dayjs from 'dayjs'; -import dayOfYear from 'dayjs/plugin/dayOfYear'; -import dedent from 'dedent'; -import { match } from 'ts-pattern'; - -import { db } from '@oyster/db'; -import { run } from '@oyster/utils'; - -import { - FeedBullJob, - type GetBullJobData, -} from '@/infrastructure/bull/bull.types'; -import { job } from '@/infrastructure/bull/use-cases/job'; -import { registerWorker } from '@/infrastructure/bull/use-cases/register-worker'; -import { ENV } from '@/shared/env'; - -// Environment Variables - -const SLACK_FEED_CHANNEL_ID = process.env.SLACK_FEED_CHANNEL_ID || ''; - -// Worker - -export const feedWorker = registerWorker('feed', FeedBullJob, async (job) => { - return match(job) - .with({ name: 'feed.slack.recurring' }, ({ data }) => { - return sendFeedSlackNotification(data); - }) - .exhaustive(); -}); - -dayjs.extend(dayOfYear); - -/** - * Sends a Slack notification to our "feed" channel on a daily basis. It serves - * as a "digest" of things that happened the day before, particularly in the - * Member Profile. - * - * For now, we're only including resources that were posted in the Resource - * Database. In the future, we'll expand this to include other things like - * company reviews, new members in the directory, etc. - */ -async function sendFeedSlackNotification( - _: GetBullJobData<'feed.slack.recurring'> -) { - // We're filtering for things that happened yesterday -- we'll use the PT - // timezone so that everyone is on the same page. - const yesterday = dayjs().tz('America/Los_Angeles').subtract(1, 'day'); - - const startOfYesterday = yesterday.startOf('day').toDate(); - const endOfYesterday = yesterday.endOf('day').toDate(); - - const resources = await db - .selectFrom('resources') - .leftJoin('students', 'students.id', 'resources.postedBy') - .select([ - 'resources.id', - 'resources.title', - 'students.slackId as posterSlackId', - ]) - .where('resources.postedAt', '>=', startOfYesterday) - .where('resources.postedAt', '<=', endOfYesterday) - .execute(); - - if (!resources.length) { - return; - } - - const message = run(() => { - const resourceItems = resources - .map((resource) => { - const url = new URL('/resources', ENV.STUDENT_PROFILE_URL); - - // Example: https://app.colorstack.io/resources?id=123 - url.searchParams.set('id', resource.id); - - return `• <${url}|*${resource.title}*> by <@${resource.posterSlackId}>`; - }) - .join('\n'); - - const url = new URL('/resources', ENV.STUDENT_PROFILE_URL); - - // Example: https://app.colorstack.io/resources?date=2024-08-15 - url.searchParams.set('date', yesterday.format('YYYY-MM-DD')); - - const dayOfTheWeek = dayjs().format('dddd'); - const dayOfTheYear = dayjs().dayOfYear(); - - return dedent` - Morning y'all, happy ${dayOfTheWeek}! ☀️ - - The following <${url}|resources> were posted yesterday: - - ${resourceItems} - - Show some love for their contributions! ❤️ - - #TheFeed #Day${dayOfTheYear} - `; - }); - - job('notification.slack.send', { - channel: SLACK_FEED_CHANNEL_ID, - message, - workspace: 'regular', - }); -} diff --git a/packages/core/src/modules/feed/feed.ts b/packages/core/src/modules/feed/feed.ts new file mode 100644 index 00000000..14b88138 --- /dev/null +++ b/packages/core/src/modules/feed/feed.ts @@ -0,0 +1,209 @@ +import dayjs from 'dayjs'; +import dayOfYear from 'dayjs/plugin/dayOfYear'; +import dedent from 'dedent'; +import { match } from 'ts-pattern'; + +import { db } from '@oyster/db'; + +import { + FeedBullJob, + type GetBullJobData, +} from '@/infrastructure/bull/bull.types'; +import { job } from '@/infrastructure/bull/use-cases/job'; +import { registerWorker } from '@/infrastructure/bull/use-cases/register-worker'; +import { ENV } from '@/shared/env'; + +// Environment Variables + +const SLACK_FEED_CHANNEL_ID = process.env.SLACK_FEED_CHANNEL_ID || ''; + +// Worker + +export const feedWorker = registerWorker('feed', FeedBullJob, async (job) => { + return match(job) + .with({ name: 'feed.slack.recurring' }, ({ data }) => { + return sendFeedSlackNotification(data); + }) + .exhaustive(); +}); + +// Send Feed Slack Notification + +dayjs.extend(dayOfYear); + +/** + * Sends a Slack notification to our "feed" channel on a daily basis. It serves + * as a "digest" of things that happened the day before, particularly in the + * Member Profile. + * + * For now, this includes resources, members that joined the directory, and + * company reviews. The scope of this will likely expand as we introduce more + * tools in the Member Profile. + */ +async function sendFeedSlackNotification( + _: GetBullJobData<'feed.slack.recurring'> +) { + const [companyReviewsMessage, membersMessage, resourcesMessage] = + await Promise.all([ + getCompanyReviewsMessage(), + getMembersMessage(), + getResourcesMessage(), + ]); + + const messages = [ + membersMessage, + resourcesMessage, + companyReviewsMessage, + ].filter(Boolean); + + if (!messages.length) { + return; + } + + const dayOfTheWeek = dayjs().tz('America/Los_Angeles').format('dddd'); + + const message = dedent` + Morning y'all, happy ${dayOfTheWeek}! ☀️ + + ${messages.join('\n\n')} + + Show some love for their engagement! ❤️ + `; + + job('notification.slack.send', { + channel: SLACK_FEED_CHANNEL_ID, + message, + workspace: 'regular', + }); +} + +async function getCompanyReviewsMessage(): Promise { + const { endOfYesterday, startOfYesterday } = getYesterdayRange(); + + const companyReviews = await db + .selectFrom('companyReviews') + .leftJoin('students', 'students.id', 'companyReviews.studentId') + .leftJoin('workExperiences', 'workExperiences.id', 'workExperienceId') + .leftJoin('companies', 'companies.id', 'workExperiences.companyId') + .select([ + 'companies.name as companyName', + 'companyReviews.id', + 'companyReviews.rating', + 'students.slackId as posterSlackId', + 'workExperiences.companyId', + ]) + .where('companyReviews.createdAt', '>=', startOfYesterday) + .where('companyReviews.createdAt', '<=', endOfYesterday) + .execute(); + + if (!companyReviews.length) { + return null; + } + + const items = companyReviews + .map(({ companyId, companyName, posterSlackId, rating }) => { + const url = new URL('/companies/' + companyId, ENV.STUDENT_PROFILE_URL); + + return `• <${url}|*${companyName}*> (${rating}/10) by <@${posterSlackId}>`; + }) + .join('\n'); + + const title = + items.length === 1 + ? 'Check out this company review posted yesterday! 💼' + : 'Check out these company reviews posted yesterday! 💼'; + + return dedent` + ${title} + ${items} + `; +} + +async function getMembersMessage(): Promise { + const { endOfYesterday, startOfYesterday, yesterday } = getYesterdayRange(); + + const members = await db + .selectFrom('students') + .where('joinedMemberDirectoryAt', '>=', startOfYesterday) + .where('joinedMemberDirectoryAt', '<=', endOfYesterday) + .execute(); + + if (!members.length) { + return null; + } + + const url = new URL('/directory', ENV.STUDENT_PROFILE_URL); + + url.searchParams.set('joinedDirectoryDate', yesterday.format('YYYY-MM-DD')); + + const title = + members.length === 1 + ? `Say hello to the <${url}|${members.length} member> who joined the Member Directory yesterday! 👋` + : `Say hello to the <${url}|${members.length} members> who joined the Member Directory yesterday! 👋`; + + return dedent` + ${title} + `; +} + +async function getResourcesMessage(): Promise { + const { endOfYesterday, startOfYesterday, yesterday } = getYesterdayRange(); + + const resources = await db + .selectFrom('resources') + .leftJoin('students', 'students.id', 'resources.postedBy') + .select([ + 'resources.id', + 'resources.title', + 'students.slackId as posterSlackId', + ]) + .where('resources.postedAt', '>=', startOfYesterday) + .where('resources.postedAt', '<=', endOfYesterday) + .execute(); + + if (!resources.length) { + return null; + } + + const items = resources + .map((resource) => { + const url = new URL('/resources', ENV.STUDENT_PROFILE_URL); + + // Example: https://app.colorstack.io/resources?id=123 + url.searchParams.set('id', resource.id); + + return `• <${url}|*${resource.title}*> by <@${resource.posterSlackId}>`; + }) + .join('\n'); + + const url = new URL('/resources', ENV.STUDENT_PROFILE_URL); + + // Example: https://app.colorstack.io/resources?date=2024-08-15 + url.searchParams.set('date', yesterday.format('YYYY-MM-DD')); + + const title = + items.length === 1 + ? `Check out this <${url}|resource> posted yesterday! 📚` + : `Check out these <${url}|resources> posted yesterday! 📚`; + + return dedent` + ${title} + ${items} + `; +} + +/** + * Returns the datetime for the start and end of yesterday. + * + * We're using the PT timezone for this, since we want to show a consistent + * range for everyone, despite where they are. + */ +function getYesterdayRange() { + const yesterday = dayjs().tz('America/Los_Angeles').subtract(1, 'day'); + + return { + endOfYesterday: yesterday.endOf('day').toDate(), + startOfYesterday: yesterday.startOf('day').toDate(), + yesterday, + }; +} diff --git a/packages/core/src/modules/member/events/activation-step-completed.ts b/packages/core/src/modules/member/events/activation-step-completed.ts index 8275b73e..fb803cbe 100644 --- a/packages/core/src/modules/member/events/activation-step-completed.ts +++ b/packages/core/src/modules/member/events/activation-step-completed.ts @@ -5,7 +5,6 @@ import { db } from '@oyster/db'; import { ActivationRequirement, type Student } from '@oyster/types'; import { job } from '@/infrastructure/bull/use-cases/job'; -import { ACTIVATION_FLOW_LAUNCH_DATE } from '@/shared/constants'; import { ENV } from '@/shared/env'; import { ErrorWithContext } from '@/shared/errors'; @@ -121,7 +120,7 @@ async function shouldProcess({ return false; } - if (dayjs(acceptedAt).isBefore(ACTIVATION_FLOW_LAUNCH_DATE)) { + if (dayjs(acceptedAt).isBefore('2023-06-09')) { return false; } @@ -250,7 +249,7 @@ async function sendProgressNotification({ You're making some great progress on your activation! You've now completed ${completedRequirements}/${totalRequirements} requirements. - See an updated checklist in your ! 👀 + See an updated checklist in your ! 👀 `; } diff --git a/packages/core/src/modules/member/member.ts b/packages/core/src/modules/member/member.ts new file mode 100644 index 00000000..ed254c86 --- /dev/null +++ b/packages/core/src/modules/member/member.ts @@ -0,0 +1,40 @@ +import { type ExpressionBuilder, sql } from 'kysely'; + +import { type DB } from '@oyster/db'; + +/** + * Builds the "full name" of a member using the first, last and preferred name! + * + * If there is a preferred name, it will be used in parenthesis between the + * first/last name. + * + * @example + * "Jehron Petty" + * "Michelle Figueroa" + * "Jehron (Jayo) Petty" + * "Michelle (Shay) Figueroa" + */ +export function buildFullName(eb: ExpressionBuilder) { + const field = eb.fn('concat', [ + 'firstName', + + eb + .case() + .when('preferredName', 'is not', null) + .then( + eb.fn('concat', [ + sql.lit(' '), + sql.lit('('), + eb.ref('preferredName'), + sql.lit(')'), + sql.lit(' '), + ]) + ) + .else(sql.lit(' ')) + .end(), + + 'lastName', + ]); + + return field.as('fullName'); +} diff --git a/packages/core/src/modules/member/use-cases/backfill-engagement-records.ts b/packages/core/src/modules/member/use-cases/backfill-engagement-records.ts index f5147934..d1ebdf9e 100644 --- a/packages/core/src/modules/member/use-cases/backfill-engagement-records.ts +++ b/packages/core/src/modules/member/use-cases/backfill-engagement-records.ts @@ -195,9 +195,10 @@ export async function backfillEngagementRecords( }); surveyResponses.forEach((response) => { - job('survey.responded', { + job('gamification.activity.completed', { studentId: student.id, - surveyId: response.surveyId, + surveyRespondedTo: response.surveyId, + type: 'respond_to_survey', }); }); diff --git a/packages/core/src/modules/resource/queries/list-resources.ts b/packages/core/src/modules/resource/queries/list-resources.ts index 874797c1..d7aaab79 100644 --- a/packages/core/src/modules/resource/queries/list-resources.ts +++ b/packages/core/src/modules/resource/queries/list-resources.ts @@ -57,6 +57,8 @@ export async function listResources< return eb.or([ eb('title', 'ilike', `%${search}%`), eb(sql`word_similarity(title, ${search})`, '>=', 0.25), + eb('description', 'ilike', `%${search}%`), + eb(sql`word_similarity(description, ${search})`, '>=', 0.25), ]); }); }) diff --git a/packages/core/src/modules/sentry/use-cases/report-exception.ts b/packages/core/src/modules/sentry/use-cases/report-exception.ts index a82c66c3..b8e5bb08 100644 --- a/packages/core/src/modules/sentry/use-cases/report-exception.ts +++ b/packages/core/src/modules/sentry/use-cases/report-exception.ts @@ -2,16 +2,30 @@ import * as Sentry from '@sentry/node'; import { type ErrorContext, ErrorWithContext } from '@/shared/errors'; -export function reportException(error: unknown): void { - let context: ErrorContext | undefined = undefined; +/** + * Reports an exception to Sentry and logs the error to the console. + * + * @param error - The error-like object to report. + * @param context - Additional context to report with the error. + */ +export function reportException(error: unknown, context?: ErrorContext): void { + let extra: ErrorContext | undefined; - if (error instanceof ErrorWithContext && error.context) { - context = error.context; - } + const isErrorWithContext = error instanceof ErrorWithContext; - console.error(error); + if (context || isErrorWithContext) { + extra = { + ...context, + ...(isErrorWithContext && error.context), + }; + } Sentry.captureException(error, { - extra: context, + extra, + }); + + console.error({ + error, + ...(extra && { extra }), }); } diff --git a/packages/core/src/modules/survey/events/survey.responded.ts b/packages/core/src/modules/survey/events/survey.responded.ts deleted file mode 100644 index c825e3a5..00000000 --- a/packages/core/src/modules/survey/events/survey.responded.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { job } from '@/infrastructure/bull/use-cases/job'; - -export async function onRespondedToSurvey({ - studentId, - surveyId, -}: GetBullJobData<'survey.responded'>) { - job('gamification.activity.completed', { - studentId, - surveyRespondedTo: surveyId, - type: 'respond_to_survey', - }); -} diff --git a/packages/core/src/modules/survey/survey.worker.ts b/packages/core/src/modules/survey/survey.worker.ts deleted file mode 100644 index ce19b306..00000000 --- a/packages/core/src/modules/survey/survey.worker.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { match } from 'ts-pattern'; - -import { SurveyBullJob } from '@/infrastructure/bull/bull.types'; -import { registerWorker } from '@/infrastructure/bull/use-cases/register-worker'; -import { onRespondedToSurvey } from './events/survey.responded'; - -export const surveyWorker = registerWorker( - 'survey', - SurveyBullJob, - async (job) => { - return match(job) - .with({ name: 'survey.responded' }, ({ data }) => { - return onRespondedToSurvey(data); - }) - .exhaustive(); - } -); diff --git a/packages/core/src/modules/survey/use-cases/import-survey-responses.ts b/packages/core/src/modules/survey/use-cases/import-survey-responses.ts index f0ac183d..bf580fd7 100644 --- a/packages/core/src/modules/survey/use-cases/import-survey-responses.ts +++ b/packages/core/src/modules/survey/use-cases/import-survey-responses.ts @@ -61,9 +61,10 @@ export async function importSurveyResponses( responses.forEach((response) => { if (response.studentId) { - job('survey.responded', { + job('gamification.activity.completed', { studentId: response.studentId, - surveyId: response.surveyId, + surveyRespondedTo: response.surveyId, + type: 'respond_to_survey', }); } }); diff --git a/packages/core/src/modules/swag-pack/swag-pack.service.ts b/packages/core/src/modules/swag-pack/swag-pack.service.ts index bd1c5eca..118732f8 100644 --- a/packages/core/src/modules/swag-pack/swag-pack.service.ts +++ b/packages/core/src/modules/swag-pack/swag-pack.service.ts @@ -80,7 +80,7 @@ export async function orderSwagPack(input: OrderSwagPackInput) { shipping_address1: input.contact.address.line1, shipping_address2: input.contact.address.line2, shipping_city: input.contact.address.city, - shipping_country: 'US', + shipping_country: input.contact.address.country, shipping_state: input.contact.address.state, shipping_zip: input.contact.address.zip, }, diff --git a/packages/core/src/modules/swag-pack/swag-pack.types.ts b/packages/core/src/modules/swag-pack/swag-pack.types.ts index 687113cb..3a3e85ec 100644 --- a/packages/core/src/modules/swag-pack/swag-pack.types.ts +++ b/packages/core/src/modules/swag-pack/swag-pack.types.ts @@ -4,6 +4,7 @@ import { Address, Student } from '@oyster/types'; export const ClaimSwagPackInput = z.object({ addressCity: Address.shape.city, + addressCountry: Address.shape.country, addressLine1: Address.shape.line1, addressLine2: Address.shape.line2, addressState: Address.shape.state, diff --git a/packages/core/src/modules/swag-pack/use-cases/claim-swag-pack.ts b/packages/core/src/modules/swag-pack/use-cases/claim-swag-pack.ts index e11d36ae..6c46827a 100644 --- a/packages/core/src/modules/swag-pack/use-cases/claim-swag-pack.ts +++ b/packages/core/src/modules/swag-pack/use-cases/claim-swag-pack.ts @@ -1,26 +1,72 @@ +import dedent from 'dedent'; + import { db } from '@oyster/db'; +import { job } from '@/infrastructure/bull/use-cases/job'; +import { fail, type Result, success } from '@/shared/utils/core.utils'; import { orderSwagPack } from '../swag-pack.service'; import { type ClaimSwagPackInput } from '../swag-pack.types'; export async function claimSwagPack({ addressCity, + addressCountry, addressLine1, addressLine2, addressState, addressZip, studentId, -}: ClaimSwagPackInput) { +}: ClaimSwagPackInput): Promise { + // We save the address regardless if the swag pack order failed or not so + // we'll be able to send them something in the future. const student = await db - .selectFrom('students') - .select(['email', 'firstName', 'lastName']) + .updateTable('students') + .set({ + addressCity, + addressCountry, + addressLine1, + addressLine2, + addressState, + addressZip, + }) .where('id', '=', studentId) + .returning(['email', 'firstName', 'lastName']) .executeTakeFirstOrThrow(); + // Currently, SwagUp only supports the US, but not Puerto Rico. + // See: https://support.swagup.com/en/articles/6952397-international-shipments-restricted-items + const isAddressSupported = addressCountry === 'US' && addressState !== 'PR'; + + // If the address isn't supported, then we'll send a notification to our + // team to create a gift card manually for them. + if (!isAddressSupported) { + const notification = dedent` + ${student.firstName} ${student.lastName} (${student.email}) is attempting to claim a swag pack, but they're either from Puerto Rico or Canada, which is not supported for our product. + + We let them know we'll send them a merch store gift card in the next "few days"! + `; + + job('notification.slack.send', { + message: notification, + workspace: 'internal', + }); + + const error = dedent` + Unfortunately, our swag pack provider, SwagUp, does not support shipments to Puerto Rico and Canada. Instead, we will send you a gift card to our official merch store. + + Our team has been notified, please give us a few days to complete this request! + `; + + return fail({ + code: 400, + error, + }); + } + const swagPackOrderId = await orderSwagPack({ contact: { address: { city: addressCity, + country: addressCountry, line1: addressLine1, line2: addressLine2, state: addressState, @@ -35,14 +81,11 @@ export async function claimSwagPack({ await db .updateTable('students') .set({ - addressCity, - addressLine1, - addressLine2, - addressState, - addressZip, claimedSwagPackAt: new Date(), swagUpOrderId: swagPackOrderId, }) .where('id', '=', studentId) .execute(); + + return success({}); } diff --git a/packages/core/src/shared/constants.ts b/packages/core/src/shared/constants.ts deleted file mode 100644 index 86130dc7..00000000 --- a/packages/core/src/shared/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -import dayjs from 'dayjs'; - -export const ACTIVATION_FLOW_LAUNCH_DATE = dayjs() - .year(2023) - .month(5) - .date(9) - .startOf('day') - .toDate(); diff --git a/packages/db/src/migrations/20240821170820_company_review_upvotes.ts b/packages/db/src/migrations/20240821170820_company_review_upvotes.ts new file mode 100644 index 00000000..7716dce7 --- /dev/null +++ b/packages/db/src/migrations/20240821170820_company_review_upvotes.ts @@ -0,0 +1,34 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely) { + await db.schema + .createTable('company_review_upvotes') + .addColumn('created_at', 'timestamptz', (column) => { + return column.notNull().defaultTo(sql`now()`); + }) + .addColumn('company_review_id', 'text', (column) => { + return column.notNull().references('company_reviews.id'); + }) + .addColumn('student_id', 'text', (column) => { + return column.notNull().references('students.id'); + }) + .addPrimaryKeyConstraint('company_review_upvotes_pkey', [ + 'company_review_id', + 'student_id', + ]) + .execute(); + + await db.schema + .createIndex('company_review_upvotes_company_review_id_idx') + .on('company_review_upvotes') + .column('company_review_id') + .execute(); +} + +export async function down(db: Kysely) { + await db.schema + .dropIndex('company_review_upvotes_company_review_id_idx') + .execute(); + + await db.schema.dropTable('company_review_upvotes').execute(); +} diff --git a/packages/db/src/migrations/20240822172825_students_address_country.ts b/packages/db/src/migrations/20240822172825_students_address_country.ts new file mode 100644 index 00000000..9f4e60aa --- /dev/null +++ b/packages/db/src/migrations/20240822172825_students_address_country.ts @@ -0,0 +1,15 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely) { + await db.schema + .alterTable('students') + .addColumn('address_country', 'text') + .execute(); +} + +export async function down(db: Kysely) { + await db.schema + .alterTable('students') + .dropColumn('address_country') + .execute(); +} diff --git a/packages/db/src/migrations/20240827233709_school_tags.ts b/packages/db/src/migrations/20240827233709_school_tags.ts new file mode 100644 index 00000000..d019e465 --- /dev/null +++ b/packages/db/src/migrations/20240827233709_school_tags.ts @@ -0,0 +1,12 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely) { + await db.schema + .alterTable('schools') + .addColumn('tags', sql`text[]`) + .execute(); +} + +export async function down(db: Kysely) { + await db.schema.alterTable('schools').dropColumn('tags').execute(); +} diff --git a/packages/db/src/scripts/create-migration.ts b/packages/db/src/scripts/create-migration.ts index c8cee4ba..a3a6514a 100644 --- a/packages/db/src/scripts/create-migration.ts +++ b/packages/db/src/scripts/create-migration.ts @@ -4,14 +4,14 @@ import path from 'path'; import prompt from 'prompt-sync'; import { fileURLToPath } from 'url'; -import { iife } from '@oyster/utils'; +import { run } from '@oyster/utils'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Copied this beautiful piece of code from knex's codebase, see here: // https://github.com/knex/knex/blob/master/lib/migrations/util/timestamp.js -const timestamp = iife(() => { +const timestamp = run(() => { const now = new Date(); return ( @@ -33,7 +33,7 @@ writeFileSync( import { Kysely } from 'kysely'; export async function up(db: Kysely) {} - + export async function down(db: Kysely) {}\n ` ); diff --git a/packages/types/src/domain/types.ts b/packages/types/src/domain/types.ts index 2d57d8fc..af350e40 100644 --- a/packages/types/src/domain/types.ts +++ b/packages/types/src/domain/types.ts @@ -17,6 +17,7 @@ export type Entity = z.infer; export const Address = z.object({ city: z.string().trim().min(1), + country: z.string().trim().min(2).max(3), line1: z.string().trim().min(1), line2: z.string().trim().optional(), state: z.string().trim().min(1), diff --git a/packages/ui/src/components/address.tsx b/packages/ui/src/components/address.tsx index 6ee20961..1fa11554 100644 --- a/packages/ui/src/components/address.tsx +++ b/packages/ui/src/components/address.tsx @@ -1,16 +1,67 @@ import { type PropsWithChildren } from 'react'; +import React, { useContext, useState } from 'react'; import { Input, type InputProps } from './input'; import { Select, type SelectProps } from './select'; +type SupportedCountry = 'CA' | 'US'; + +type AddressContext = { + country: SupportedCountry; + setCountry(country: SupportedCountry): void; +}; + +const AddressContext = React.createContext({ + country: 'US', + setCountry: (_: SupportedCountry) => {}, +}); + export const Address = ({ children }: PropsWithChildren) => { - return
    {children}
    ; + const [country, setCountry] = useState('US'); + + return ( + +
    {children}
    +
    + ); }; Address.City = function City(props: InputProps) { return ; }; +type Country = { + abbreviation: SupportedCountry; + name: string; +}; + +const COUNTRIES: Country[] = [ + { abbreviation: 'CA', name: 'Canada' }, + { abbreviation: 'US', name: 'United States' }, +]; + +Address.Country = function Country(props: SelectProps) { + const { country, setCountry } = useContext(AddressContext); + + return ( + + ); +}; + Address.HalfGrid = function HalfGrid({ children }: PropsWithChildren) { return (
    @@ -36,65 +87,87 @@ type State = { name: string; }; -const USA_STATES: State[] = [ - { abbreviation: 'AL', name: 'Alabama' }, - { abbreviation: 'AK', name: 'Alaska' }, - { abbreviation: 'AZ', name: 'Arizona' }, - { abbreviation: 'AR', name: 'Arkansas' }, - { abbreviation: 'CA', name: 'California' }, - { abbreviation: 'CO', name: 'Colorado' }, - { abbreviation: 'CT', name: 'Connecticut' }, - { abbreviation: 'DE', name: 'Delaware' }, - { abbreviation: 'DC', name: 'District Of Columbia' }, - { abbreviation: 'FL', name: 'Florida' }, - { abbreviation: 'GA', name: 'Georgia' }, - { abbreviation: 'HI', name: 'Hawaii' }, - { abbreviation: 'ID', name: 'Idaho' }, - { abbreviation: 'IL', name: 'Illinois' }, - { abbreviation: 'IN', name: 'Indiana' }, - { abbreviation: 'IA', name: 'Iowa' }, - { abbreviation: 'KS', name: 'Kansas' }, - { abbreviation: 'KY', name: 'Kentucky' }, - { abbreviation: 'LA', name: 'Louisiana' }, - { abbreviation: 'ME', name: 'Maine' }, - { abbreviation: 'MD', name: 'Maryland' }, - { abbreviation: 'MA', name: 'Massachusetts' }, - { abbreviation: 'MI', name: 'Michigan' }, - { abbreviation: 'MN', name: 'Minnesota' }, - { abbreviation: 'MS', name: 'Mississippi' }, - { abbreviation: 'MO', name: 'Missouri' }, - { abbreviation: 'MT', name: 'Montana' }, - { abbreviation: 'NE', name: 'Nebraska' }, - { abbreviation: 'NV', name: 'Nevada' }, - { abbreviation: 'NH', name: 'New Hampshire' }, - { abbreviation: 'NJ', name: 'New Jersey' }, - { abbreviation: 'NM', name: 'New Mexico' }, - { abbreviation: 'NY', name: 'New York' }, - { abbreviation: 'NC', name: 'North Carolina' }, - { abbreviation: 'ND', name: 'North Dakota' }, - { abbreviation: 'OH', name: 'Ohio' }, - { abbreviation: 'OK', name: 'Oklahoma' }, - { abbreviation: 'OR', name: 'Oregon' }, - { abbreviation: 'PA', name: 'Pennsylvania' }, - { abbreviation: 'PR', name: 'Puerto Rico' }, - { abbreviation: 'RI', name: 'Rhode Island' }, - { abbreviation: 'SC', name: 'South Carolina' }, - { abbreviation: 'SD', name: 'South Dakota' }, - { abbreviation: 'TN', name: 'Tennessee' }, - { abbreviation: 'TX', name: 'Texas' }, - { abbreviation: 'UT', name: 'Utah' }, - { abbreviation: 'VT', name: 'Vermont' }, - { abbreviation: 'VA', name: 'Virginia' }, - { abbreviation: 'WA', name: 'Washington' }, - { abbreviation: 'WV', name: 'West Virginia' }, - { abbreviation: 'WI', name: 'Wisconsin' }, - { abbreviation: 'WY', name: 'Wyoming' }, -]; +const COUNTRY_TO_STATES: Record = { + CA: [ + { abbreviation: 'AB', name: 'Alberta' }, + { abbreviation: 'BC', name: 'British Columbia' }, + { abbreviation: 'MB', name: 'Manitoba' }, + { abbreviation: 'NB', name: 'New Brunswick' }, + { abbreviation: 'NL', name: 'Newfoundland and Labrador' }, + { abbreviation: 'NT', name: 'Northwest Territories' }, + { abbreviation: 'NS', name: 'Nova Scotia' }, + { abbreviation: 'NU', name: 'Nunavut' }, + { abbreviation: 'ON', name: 'Ontario' }, + { abbreviation: 'PE', name: 'Prince Edward Island' }, + { abbreviation: 'QC', name: 'Quebec' }, + { abbreviation: 'SK', name: 'Saskatchewan' }, + { abbreviation: 'YT', name: 'Yukon' }, + ], + + US: [ + { abbreviation: 'AL', name: 'Alabama' }, + { abbreviation: 'AK', name: 'Alaska' }, + { abbreviation: 'AZ', name: 'Arizona' }, + { abbreviation: 'AR', name: 'Arkansas' }, + { abbreviation: 'CA', name: 'California' }, + { abbreviation: 'CO', name: 'Colorado' }, + { abbreviation: 'CT', name: 'Connecticut' }, + { abbreviation: 'DE', name: 'Delaware' }, + { abbreviation: 'DC', name: 'District Of Columbia' }, + { abbreviation: 'FL', name: 'Florida' }, + { abbreviation: 'GA', name: 'Georgia' }, + { abbreviation: 'HI', name: 'Hawaii' }, + { abbreviation: 'ID', name: 'Idaho' }, + { abbreviation: 'IL', name: 'Illinois' }, + { abbreviation: 'IN', name: 'Indiana' }, + { abbreviation: 'IA', name: 'Iowa' }, + { abbreviation: 'KS', name: 'Kansas' }, + { abbreviation: 'KY', name: 'Kentucky' }, + { abbreviation: 'LA', name: 'Louisiana' }, + { abbreviation: 'ME', name: 'Maine' }, + { abbreviation: 'MD', name: 'Maryland' }, + { abbreviation: 'MA', name: 'Massachusetts' }, + { abbreviation: 'MI', name: 'Michigan' }, + { abbreviation: 'MN', name: 'Minnesota' }, + { abbreviation: 'MS', name: 'Mississippi' }, + { abbreviation: 'MO', name: 'Missouri' }, + { abbreviation: 'MT', name: 'Montana' }, + { abbreviation: 'NE', name: 'Nebraska' }, + { abbreviation: 'NV', name: 'Nevada' }, + { abbreviation: 'NH', name: 'New Hampshire' }, + { abbreviation: 'NJ', name: 'New Jersey' }, + { abbreviation: 'NM', name: 'New Mexico' }, + { abbreviation: 'NY', name: 'New York' }, + { abbreviation: 'NC', name: 'North Carolina' }, + { abbreviation: 'ND', name: 'North Dakota' }, + { abbreviation: 'OH', name: 'Ohio' }, + { abbreviation: 'OK', name: 'Oklahoma' }, + { abbreviation: 'OR', name: 'Oregon' }, + { abbreviation: 'PA', name: 'Pennsylvania' }, + { abbreviation: 'PR', name: 'Puerto Rico' }, + { abbreviation: 'RI', name: 'Rhode Island' }, + { abbreviation: 'SC', name: 'South Carolina' }, + { abbreviation: 'SD', name: 'South Dakota' }, + { abbreviation: 'TN', name: 'Tennessee' }, + { abbreviation: 'TX', name: 'Texas' }, + { abbreviation: 'UT', name: 'Utah' }, + { abbreviation: 'VT', name: 'Vermont' }, + { abbreviation: 'VA', name: 'Virginia' }, + { abbreviation: 'WA', name: 'Washington' }, + { abbreviation: 'WV', name: 'West Virginia' }, + { abbreviation: 'WI', name: 'Wisconsin' }, + { abbreviation: 'WY', name: 'Wyoming' }, + ], +}; Address.State = function State(props: SelectProps) { + const { country } = useContext(AddressContext); + + const states = COUNTRY_TO_STATES[country]; + return (