Skip to content

Commit

Permalink
Update ApplicantDataTable
Browse files Browse the repository at this point in the history
Close #51
  • Loading branch information
JaneIRL committed Sep 6, 2023
1 parent d0ee4a6 commit 18a3ad2
Show file tree
Hide file tree
Showing 10 changed files with 240 additions and 96 deletions.
33 changes: 16 additions & 17 deletions src/app/admin/JsonEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,37 @@ import { fromZodError } from 'zod-validation-error'

import { Box } from '@/components/Box'
import { Button } from '@/components/Button'
import { BlockedHackerSchema } from '@/lib/db/models/BlockedHacker'
import { EventSchema } from '@/lib/db/models/Event'
import { FaqSchema } from '@/lib/db/models/Faq'
import { stringifyError } from '@/lib/utils/client'
import { fetchPost, stringifyError } from '@/lib/utils/client'

interface Props {
text: string
postUrl: string
schema: SchemaName
}

type SchemaName = 'event' | 'faq'
type SchemaName = 'event' | 'blocked_hacker'

const SchemaMap: Record<SchemaName, ZodTypeAny> = {
blocked_hacker: BlockedHackerSchema,
event: EventSchema,
faq: FaqSchema,
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function JsonEditor({ text, postUrl, schema }: Props) {
const [textVal, setTextVal] = useState(text)
const [error, setError] = useState<string>()

// const save = async () => {
// try {
// const isValid = validate()
// if (isValid) {
// await fetchPost<unknown>(postUrl, textVal)
// }
// } catch (e) {
// setError(stringifyError(e))
// }
// }
const save = async () => {
try {
const isValid = validate()
if (isValid) {
await fetchPost<unknown>(postUrl, textVal)
}
} catch (e) {
setError(stringifyError(e))
}
}

const validate = () => {
try {
Expand All @@ -59,7 +58,7 @@ export default function JsonEditor({ text, postUrl, schema }: Props) {

return (
<Box direction="column" gap="1rem">
{error}
<span className="text-hackuta-red"> {error}</span>
<textarea
title="idk"
id="json"
Expand All @@ -69,7 +68,7 @@ export default function JsonEditor({ text, postUrl, schema }: Props) {
onChange={(e) => setTextVal(e.target.value)}
/>
<Box direction="row" gap="1rem">
{/* <Button onClick={save}>Save</Button> */}
<Button onClick={save}>Save</Button>
<Button kind="secondary" onClick={validate}>
Validate
</Button>
Expand Down
110 changes: 105 additions & 5 deletions src/app/admin/applications/ApplicantDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import {
DataTableExpandedRows,
DataTableValueArray,
} from 'primereact/datatable'
import { useState } from 'react'
import { ReactFragment, useState } from 'react'
import { twJoin } from 'tailwind-merge'

import { Button } from '@/components/Button'
import { AppPermissions, hasPermission } from '@/lib/auth/shared'
import { BlockedHacker } from '@/lib/db/models/BlockedHacker'
import User, { Application } from '@/lib/db/models/User'
import { stringifyError } from '@/lib/utils/shared'

import JsonEditor from '../JsonEditor'
import { ApplicationDecideRequestBody } from './decide/route'

export type Row = Application & {
Expand All @@ -23,11 +26,13 @@ export type Row = Application & {

export interface ApplicantDataTableProps {
applications: Row[]
blockedHackers: readonly BlockedHacker[]
perms: AppPermissions
}

export default function ApplicantDataTable({
applications,
blockedHackers,
perms,
}: ApplicantDataTableProps) {
const [selectedRows, setSelectedRows] = useState<Row[]>([])
Expand All @@ -41,6 +46,34 @@ export default function ApplicantDataTable({
const hasDecisionPerm = hasPermission(perms, {
administration: { application: { decision: true } },
})
const hasBlocklistPerm = hasPermission(perms, {
administration: { application: { blocklist: true } },
})

const isBlocked = (r: Row) =>
blockedHackers.some(
(b) =>
`${b.first_name} ${b.last_name}` === `${r.firstName} ${r.lastName}`,
)

interface DisqualifierFieldProps {
children: ReactFragment | number
criterionName?: string
disqualified: boolean
}

const DisqualifierField = ({
children,
criterionName,
disqualified,
}: DisqualifierFieldProps) => (
<div
className={twJoin(disqualified ? 'bg-hackuta-red text-white' : '', 'p-2')}
>
{children}
{disqualified && criterionName ? ` (${criterionName})` : ''}
</div>
)

const decide = async (decision: NonNullable<User['applicationStatus']>) => {
try {
Expand Down Expand Up @@ -92,9 +125,45 @@ export default function ApplicantDataTable({
>
{hasDecisionPerm && <Column selectionMode="multiple" />}
{hasSensitivePerm && <Column expander />}
<Column header="First Name" field="firstName" filter sortable />
<Column header="Last Name" field="lastName" filter sortable />
<Column header="Age" field="age" filter sortable />
<Column
header="First Name"
field="firstName"
body={(r: Row) => (
<DisqualifierField disqualified={isBlocked(r)}>
{r.firstName}
</DisqualifierField>
)}
filter
sortable
/>
<Column
header="Last Name"
field="lastName"
body={(r: Row) => (
<DisqualifierField
criterionName="BLOCKED"
disqualified={isBlocked(r)}
>
{r.lastName}
</DisqualifierField>
)}
filter
sortable
/>
<Column
header="Age"
field="age"
body={(r: Row) => (
<DisqualifierField
criterionName="UNDER18"
disqualified={r.age < 18}
>
{r.age}
</DisqualifierField>
)}
filter
sortable
/>
<Column header="School" field="school" filter sortable />
<Column
header="Country of Residence"
Expand All @@ -120,7 +189,28 @@ export default function ApplicantDataTable({
)
}
/>
<Column header="Status" field="status" filter sortable />
<Column
header="Status"
field="status"
body={(r: Row) => (
<span
className={twJoin(
r.status === 'accepted'
? 'bg-[green] text-white'
: r.status === 'waitlisted'
? 'bg-hackuta-yellow text-black'
: r.status === 'rejected'
? 'bg-hackuta-red text-white'
: 'bg-black text-white',
'p-2',
)}
>
{r.status}
</span>
)}
filter
sortable
/>
</DataTable>
{hasDecisionPerm && (
<>
Expand Down Expand Up @@ -151,6 +241,16 @@ export default function ApplicantDataTable({
</div>
</>
)}
{hasBlocklistPerm && (
<details className="border-2 border-black p-2 mt-2">
<summary>Blocklist</summary>
<JsonEditor
postUrl="/admin/applications/blocklist"
schema="blocked_hacker"
text={JSON.stringify(blockedHackers, undefined, 4)}
/>
</details>
)}
</div>
)
}
44 changes: 44 additions & 0 deletions src/app/admin/applications/blocklist/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

import clientPromise from '@/lib/db'
import {
BlockedHacker,
BlockedHackerCollection,
BlockedHackerSchema,
} from '@/lib/db/models/BlockedHacker'
import logger from '@/lib/logger'
import { stringifyError } from '@/lib/utils/shared'

const BodySchema = z.object({
emails: z.string().array(),
decision: z.enum(['accepted', 'rejected', 'waitlisted']),
})

export type ApplicationDecideRequestBody = z.infer<typeof BodySchema>

export async function POST(request: NextRequest) {
try {
const body = BlockedHackerSchema.array().parse(await request.json())

const client = await clientPromise
await client
.db()
.collection<BlockedHacker>(BlockedHackerCollection)
.bulkWrite([
{
deleteMany: { filter: {} },
},
...body.map((v) => ({
insertOne: {
document: v,
},
})),
])

return NextResponse.json({ status: 'success' })
} catch (e) {
logger.error(e, request.nextUrl.pathname)
return NextResponse.json({ status: 'error', message: stringifyError(e) })
}
}
5 changes: 3 additions & 2 deletions src/app/admin/applications/decide/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from 'zod'
import clientPromise from '@/lib/db'
import User from '@/lib/db/models/User'
import logger from '@/lib/logger'
import { stringifyError } from '@/lib/utils/server'

const BodySchema = z.object({
emails: z.string().array(),
Expand All @@ -28,9 +29,9 @@ export async function POST(request: NextRequest) {
},
},
)
return NextResponse.json({})
return NextResponse.json({ status: 'success' })
} catch (e) {
logger.error(e, `[/admin/applications/decide]`)
return NextResponse.json({})
return NextResponse.json({ status: 'error', message: stringifyError(e) })
}
}
19 changes: 18 additions & 1 deletion src/app/admin/applications/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
import { headers } from 'next/headers'

import clientPromise from '@/lib/db'
import {
BlockedHacker,
BlockedHackerCollection,
} from '@/lib/db/models/BlockedHacker'
import { getEnhancedSession } from '@/lib/utils/server'

import ApplicantDataTable from './ApplicantDataTable'
import { getUsers } from './utils'

export default async function Applications() {
const { perms } = getEnhancedSession(headers())
const client = await clientPromise
const applications = ((await getUsers()) ?? [])
.filter((u) => u.application)
.map((u) => ({
...u.application!,
email: u.email,
status: u.applicationStatus ?? ('undecided' as const),
}))
const blockedHackers = await client
.db()
.collection<BlockedHacker>(BlockedHackerCollection)
.find({}, { projection: { _id: 0 } })
.toArray()

return <ApplicantDataTable applications={applications} perms={perms} />
return (
<ApplicantDataTable
applications={applications}
blockedHackers={blockedHackers}
perms={perms}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import JSZip from 'jszip'
import type { NextApiRequest, NextApiResponse } from 'next'
import { NextResponse } from 'next/server'

import clientPromise from '@/lib/db'
import User, { getFullName } from '@/lib/db/models/User'
import logger from '@/lib/logger'
import { stringifyError } from '@/lib/utils/shared'

export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
export async function GET() {
try {
if (req.method !== 'GET') {
throw new Error(`Unsupported ${req.method}`)
}

// get all users with a resume by filter
const client = await clientPromise
const users: User[] = await client
Expand Down Expand Up @@ -42,12 +36,16 @@ export default async function handler(

// send the zip file
const zip = await jzip.generateAsync({ type: 'nodebuffer' })
res.setHeader('Content-Type', 'application/zip')
res.setHeader('Content-Disposition', 'attachment; filename=resumes.zip')
const res = new NextResponse(zip, {
headers: {
'Content-Disposition': 'attachment; filename=resumes.zip',
'Content-Type': 'application/zip',
},
})

return res.status(200).send(zip)
return res
} catch (e) {
logger.error(e, '[/api/admin/resumes]')
return res.status(500)
return NextResponse.json({ status: 'error', message: stringifyError(e) })
}
}
Loading

0 comments on commit 18a3ad2

Please sign in to comment.