mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-19 21:33:30 +08:00
[resumes][feat] Add API to submit & query for resume reviews (#313)
* [resumes][feat] Add route to submit resume reviews * [resumes][feat] Add router to query for comments * [resumes][refactor] Change limit of upvotes query * [resumes][chore] revert changes * [resumes][chore] remove comment * [resumes][chore] Use ResumesSection enum instead of hard-coded string * [resumes][refactor] Add check for user session in comments * [resumes][fix] fix linting issues Co-authored-by: Terence Ho <>
This commit is contained in:
@ -3,7 +3,10 @@ import type { SubmitHandler } from 'react-hook-form';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Button, Dialog, TextInput } from '@tih/ui';
|
import { Button, Dialog, TextInput } from '@tih/ui';
|
||||||
|
|
||||||
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
type CommentsFormProps = Readonly<{
|
type CommentsFormProps = Readonly<{
|
||||||
|
resumeId: string;
|
||||||
setShowCommentsForm: (show: boolean) => void;
|
setShowCommentsForm: (show: boolean) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@ -18,6 +21,7 @@ type IFormInput = {
|
|||||||
type InputKeys = keyof IFormInput;
|
type InputKeys = keyof IFormInput;
|
||||||
|
|
||||||
export default function CommentsForm({
|
export default function CommentsForm({
|
||||||
|
resumeId,
|
||||||
setShowCommentsForm,
|
setShowCommentsForm,
|
||||||
}: CommentsFormProps) {
|
}: CommentsFormProps) {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
@ -35,10 +39,17 @@ export default function CommentsForm({
|
|||||||
skills: '',
|
skills: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create');
|
||||||
|
|
||||||
// TODO: Implement mutation to database
|
// TODO: Give a feedback to the user if the action succeeds/fails
|
||||||
const onSubmit: SubmitHandler<IFormInput> = (data) => {
|
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
||||||
alert(JSON.stringify(data));
|
await reviewCreateMutation.mutate({
|
||||||
|
resumeId,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect back to comments section
|
||||||
|
setShowCommentsForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
@ -54,8 +65,11 @@ export default function CommentsForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="h-[calc(100vh-13rem)] overflow-y-scroll">
|
||||||
<h2 className="text-xl font-semibold text-gray-800">Add your review</h2>
|
<h2 className="text-xl font-semibold text-gray-800">Add your review</h2>
|
||||||
|
<p className="text-gray-800">
|
||||||
|
Please fill in at least one section to submit your review
|
||||||
|
</p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="w-full space-y-8 divide-y divide-gray-200"
|
className="w-full space-y-8 divide-y divide-gray-200"
|
||||||
@ -144,6 +158,6 @@ export default function CommentsForm({
|
|||||||
}}>
|
}}>
|
||||||
<div>Note that your review will not be saved!</div>
|
<div>Note that your review will not be saved!</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,31 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Tabs } from '@tih/ui';
|
import { Tabs } from '@tih/ui';
|
||||||
|
|
||||||
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
|
import CommentsListButton from './CommentsListButton';
|
||||||
import { COMMENTS_SECTIONS } from './constants';
|
import { COMMENTS_SECTIONS } from './constants';
|
||||||
|
|
||||||
type CommentsListProps = Readonly<{
|
type CommentsListProps = Readonly<{
|
||||||
|
resumeId: string;
|
||||||
setShowCommentsForm: (show: boolean) => void;
|
setShowCommentsForm: (show: boolean) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export default function CommentsList({
|
export default function CommentsList({
|
||||||
|
resumeId,
|
||||||
setShowCommentsForm,
|
setShowCommentsForm,
|
||||||
}: CommentsListProps) {
|
}: CommentsListProps) {
|
||||||
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
|
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
|
||||||
|
|
||||||
|
const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]);
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.log(commentsQuery.data);
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-3">
|
||||||
<Button
|
<CommentsListButton setShowCommentsForm={setShowCommentsForm} />
|
||||||
display="block"
|
|
||||||
label="Add your review"
|
|
||||||
variant="tertiary"
|
|
||||||
onClick={() => setShowCommentsForm(true)}
|
|
||||||
/>
|
|
||||||
<Tabs
|
<Tabs
|
||||||
label="comments"
|
label="comments"
|
||||||
tabs={COMMENTS_SECTIONS}
|
tabs={COMMENTS_SECTIONS}
|
||||||
@ -27,6 +33,6 @@ export default function CommentsList({
|
|||||||
onChange={(value) => setTab(value)}
|
onChange={(value) => setTab(value)}
|
||||||
/>
|
/>
|
||||||
{/* TODO: Add comments lists */}
|
{/* TODO: Add comments lists */}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,48 @@
|
|||||||
|
import { signIn, useSession } from 'next-auth/react';
|
||||||
|
import { Button } from '@tih/ui';
|
||||||
|
|
||||||
|
type CommentsListButtonProps = {
|
||||||
|
setShowCommentsForm: (show: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CommentsListButton({
|
||||||
|
setShowCommentsForm,
|
||||||
|
}: CommentsListButtonProps) {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const isSessionLoading = status === 'loading';
|
||||||
|
|
||||||
|
// Don't render anything
|
||||||
|
if (isSessionLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not signed in
|
||||||
|
if (session == null) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
className="text-primary-800 hover:text-primary-500"
|
||||||
|
href="/api/auth/signin"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
signIn();
|
||||||
|
}}>
|
||||||
|
Sign in
|
||||||
|
</a>{' '}
|
||||||
|
to join discussion
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed in. Return Add review button
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
display="block"
|
||||||
|
label="Add your review"
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={() => setShowCommentsForm(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -3,12 +3,25 @@ import { useState } from 'react';
|
|||||||
import CommentsForm from './CommentsForm';
|
import CommentsForm from './CommentsForm';
|
||||||
import CommentsList from './CommentsList';
|
import CommentsList from './CommentsList';
|
||||||
|
|
||||||
export default function CommentsSection() {
|
type ICommentsSectionProps = {
|
||||||
|
resumeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Retrieve resumeId for CommentsSection
|
||||||
|
export default function CommentsSection({
|
||||||
|
resumeId = '',
|
||||||
|
}: ICommentsSectionProps) {
|
||||||
const [showCommentsForm, setShowCommentsForm] = useState(false);
|
const [showCommentsForm, setShowCommentsForm] = useState(false);
|
||||||
|
|
||||||
return showCommentsForm ? (
|
return showCommentsForm ? (
|
||||||
<CommentsForm setShowCommentsForm={setShowCommentsForm} />
|
<CommentsForm
|
||||||
|
resumeId={resumeId}
|
||||||
|
setShowCommentsForm={setShowCommentsForm}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CommentsList setShowCommentsForm={setShowCommentsForm} />
|
<CommentsList
|
||||||
|
resumeId={resumeId}
|
||||||
|
setShowCommentsForm={setShowCommentsForm}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,24 @@
|
|||||||
// TODO: Move to a general enums/constants file? For resumes
|
import { ResumesSection } from '@prisma/client';
|
||||||
|
|
||||||
export const COMMENTS_SECTIONS = [
|
export const COMMENTS_SECTIONS = [
|
||||||
{
|
{
|
||||||
label: 'General',
|
label: 'General',
|
||||||
value: 'general',
|
value: ResumesSection.GENERAL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Education',
|
label: 'Education',
|
||||||
value: 'education',
|
value: ResumesSection.EDUCATION,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Experience',
|
label: 'Experience',
|
||||||
value: 'experience',
|
value: ResumesSection.EXPERIENCE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Projects',
|
label: 'Projects',
|
||||||
value: 'projects',
|
value: ResumesSection.PROJECTS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Skills',
|
label: 'Skills',
|
||||||
value: 'skills',
|
value: ResumesSection.SKILLS,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -73,7 +73,7 @@ export default function ResumeReviewPage() {
|
|||||||
<ResumePdf />
|
<ResumePdf />
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-8 w-1/2">
|
<div className="mx-8 w-1/2">
|
||||||
<CommentsSection />
|
<CommentsSection resumeId="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
@ -3,6 +3,8 @@ import superjson from 'superjson';
|
|||||||
import { createRouter } from './context';
|
import { createRouter } from './context';
|
||||||
import { protectedExampleRouter } from './protected-example-router';
|
import { protectedExampleRouter } from './protected-example-router';
|
||||||
import { resumesResumeUserRouter } from './resumes-resume-user-router';
|
import { resumesResumeUserRouter } from './resumes-resume-user-router';
|
||||||
|
import { resumeReviewsRouter } from './resumes-reviews-router';
|
||||||
|
import { resumesReviewsUserRouter } from './resumes-reviews-user-router';
|
||||||
import { todosRouter } from './todos';
|
import { todosRouter } from './todos';
|
||||||
import { todosUserRouter } from './todos-user-router';
|
import { todosUserRouter } from './todos-user-router';
|
||||||
|
|
||||||
@ -14,7 +16,9 @@ export const appRouter = createRouter()
|
|||||||
.merge('auth.', protectedExampleRouter)
|
.merge('auth.', protectedExampleRouter)
|
||||||
.merge('todos.', todosRouter)
|
.merge('todos.', todosRouter)
|
||||||
.merge('todos.user.', todosUserRouter)
|
.merge('todos.user.', todosUserRouter)
|
||||||
.merge('resumes.resume.user.', resumesResumeUserRouter);
|
.merge('resumes.resume.user.', resumesResumeUserRouter)
|
||||||
|
.merge('resumes.reviews.', resumeReviewsRouter)
|
||||||
|
.merge('resumes.reviews.user.', resumesReviewsUserRouter);
|
||||||
|
|
||||||
// Export type definition of API
|
// Export type definition of API
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
44
apps/portal/src/server/router/resumes-reviews-router.ts
Normal file
44
apps/portal/src/server/router/resumes-reviews-router.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { createRouter } from './context';
|
||||||
|
|
||||||
|
export const resumeReviewsRouter = createRouter().query('list', {
|
||||||
|
input: z.object({
|
||||||
|
resumeId: z.string(),
|
||||||
|
}),
|
||||||
|
async resolve({ ctx, input }) {
|
||||||
|
const userId = ctx.session?.user?.id;
|
||||||
|
const { resumeId } = input;
|
||||||
|
|
||||||
|
// For this resume, we retrieve every comment's information, along with:
|
||||||
|
// The user's name and image to render
|
||||||
|
// Number of votes, and whether the user (if-any) has voted
|
||||||
|
return await ctx.prisma.resumesComment.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
votes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
image: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
votes: {
|
||||||
|
take: 1,
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
resumeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
54
apps/portal/src/server/router/resumes-reviews-user-router.ts
Normal file
54
apps/portal/src/server/router/resumes-reviews-user-router.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { ResumesSection } from '@prisma/client';
|
||||||
|
|
||||||
|
import { createProtectedRouter } from './context';
|
||||||
|
|
||||||
|
type IResumeCommentInput = Readonly<{
|
||||||
|
description: string;
|
||||||
|
resumeId: string;
|
||||||
|
section: ResumesSection;
|
||||||
|
userId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const resumesReviewsUserRouter = createProtectedRouter().mutation(
|
||||||
|
'create',
|
||||||
|
{
|
||||||
|
input: z.object({
|
||||||
|
education: z.string(),
|
||||||
|
experience: z.string(),
|
||||||
|
general: z.string(),
|
||||||
|
projects: z.string(),
|
||||||
|
resumeId: z.string(),
|
||||||
|
skills: z.string(),
|
||||||
|
}),
|
||||||
|
async resolve({ ctx, input }) {
|
||||||
|
const userId = ctx.session?.user.id;
|
||||||
|
const { resumeId, education, experience, general, projects, skills } =
|
||||||
|
input;
|
||||||
|
|
||||||
|
// For each section, convert them into ResumesComment model if provided
|
||||||
|
const comments: Array<IResumeCommentInput> = [
|
||||||
|
{ description: education, section: ResumesSection.EDUCATION },
|
||||||
|
{ description: experience, section: ResumesSection.EXPERIENCE },
|
||||||
|
{ description: general, section: ResumesSection.GENERAL },
|
||||||
|
{ description: projects, section: ResumesSection.PROJECTS },
|
||||||
|
{ description: skills, section: ResumesSection.SKILLS },
|
||||||
|
]
|
||||||
|
.filter(({ description }) => {
|
||||||
|
return description.trim().length > 0;
|
||||||
|
})
|
||||||
|
.map(({ description, section }) => {
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
resumeId,
|
||||||
|
section,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return await ctx.prisma.resumesComment.createMany({
|
||||||
|
data: comments,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
Reference in New Issue
Block a user