;
+ user: User | null;
},
inputToken: string | undefined,
+ inputUserId: string | null | undefined
) => {
const profileDto: Profile = {
analysis: profileAnalysisDtoMapper(profile.analysis),
@@ -535,6 +538,7 @@ export const profileDtoMapper = (
editToken: null,
id: profile.id,
isEditable: false,
+ isSaved: false,
offers: profile.offers.map((offer) => profileOfferDtoMapper(offer)),
profileName: profile.profileName,
};
@@ -542,6 +546,20 @@ export const profileDtoMapper = (
if (inputToken === profile.editToken) {
profileDto.editToken = profile.editToken ?? null;
profileDto.isEditable = true;
+
+ const users = profile.user
+
+ // TODO: BRYANN UNCOMMENT THIS ONCE U CHANGE THE SCHEMA
+ // for (let i = 0; i < users.length; i++) {
+ // if (users[i].id === inputUserId) {
+ // profileDto.isSaved = true
+ // }
+ // }
+
+ // TODO: REMOVE THIS ONCE U CHANGE THE SCHEMA
+ if (users?.id === inputUserId) {
+ profileDto.isSaved = true
+ }
}
return profileDto;
@@ -626,3 +644,84 @@ export const getOffersResponseMapper = (
};
return getOffersResponse;
};
+
+export const getUserProfileResponseMapper = (res: User & {
+ OffersProfile: Array;
+ }>;
+} | null): Array => {
+ if (res) {
+ return res.OffersProfile.map((profile) => {
+ return {
+ createdAt: profile.createdAt,
+ id: profile.id,
+ offers: profile.offers.map((offer) => {
+ return userProfileOfferDtoMapper(offer)
+ }),
+ profileName: profile.profileName,
+ token: profile.editToken
+ }
+ })
+ }
+
+ return []
+}
+
+const userProfileOfferDtoMapper = (
+ offer: OffersOffer & {
+ company: Company;
+ offersFullTime: (OffersFullTime & { totalCompensation: OffersCurrency }) | null;
+ offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ }): UserProfileOffer => {
+ const mappedOffer: UserProfileOffer = {
+ company: offersCompanyDtoMapper(offer.company),
+ id: offer.id,
+ income: {
+ baseCurrency: '',
+ baseValue: -1,
+ currency: '',
+ id: '',
+ value: -1,
+ },
+ jobType: offer.jobType,
+ level: offer.offersFullTime?.level ?? '',
+ location: offer.location,
+ monthYearReceived: offer.monthYearReceived,
+ title:
+ offer.jobType === JobType.FULLTIME
+ ? offer.offersFullTime?.title ?? ''
+ : offer.offersIntern?.title ?? '',
+ }
+
+ if (offer.offersFullTime?.totalCompensation) {
+ mappedOffer.income.value =
+ offer.offersFullTime.totalCompensation.value;
+ mappedOffer.income.currency =
+ offer.offersFullTime.totalCompensation.currency;
+ mappedOffer.income.id = offer.offersFullTime.totalCompensation.id;
+ mappedOffer.income.baseValue =
+ offer.offersFullTime.totalCompensation.baseValue;
+ mappedOffer.income.baseCurrency =
+ offer.offersFullTime.totalCompensation.baseCurrency;
+ } else if (offer.offersIntern?.monthlySalary) {
+ mappedOffer.income.value = offer.offersIntern.monthlySalary.value;
+ mappedOffer.income.currency =
+ offer.offersIntern.monthlySalary.currency;
+ mappedOffer.income.id = offer.offersIntern.monthlySalary.id;
+ mappedOffer.income.baseValue =
+ offer.offersIntern.monthlySalary.baseValue;
+ mappedOffer.income.baseCurrency =
+ offer.offersIntern.monthlySalary.baseCurrency;
+ } else {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Total Compensation or Salary not found',
+ });
+ }
+
+ return mappedOffer
+}
\ No newline at end of file
diff --git a/apps/portal/src/pages/offers/profile/[offerProfileId].tsx b/apps/portal/src/pages/offers/profile/[offerProfileId].tsx
index 7cf2811d..77229cc1 100644
--- a/apps/portal/src/pages/offers/profile/[offerProfileId].tsx
+++ b/apps/portal/src/pages/offers/profile/[offerProfileId].tsx
@@ -50,11 +50,11 @@ export default function OfferProfile() {
router.push(HOME_URL);
}
// If the profile is not editable with a wrong token, redirect to the profile page
- if (!data?.isEditable && token !== '') {
+ if (!data.isEditable && token !== '') {
router.push(getProfilePath(offerProfileId as string));
}
- setIsEditable(data?.isEditable ?? false);
+ setIsEditable(data.isEditable);
const filteredOffers: Array = data
? data?.offers.map((res: ProfileOffer) => {
diff --git a/apps/portal/src/pages/offers/submit/index.tsx b/apps/portal/src/pages/offers/submit/index.tsx
new file mode 100644
index 00000000..df2015f1
--- /dev/null
+++ b/apps/portal/src/pages/offers/submit/index.tsx
@@ -0,0 +1,5 @@
+import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
+
+export default function OffersSubmissionPage() {
+ return ;
+}
diff --git a/apps/portal/src/pages/offers/submit/result/[offerProfileId].tsx b/apps/portal/src/pages/offers/submit/result/[offerProfileId].tsx
new file mode 100644
index 00000000..dd379145
--- /dev/null
+++ b/apps/portal/src/pages/offers/submit/result/[offerProfileId].tsx
@@ -0,0 +1,123 @@
+import { useRouter } from 'next/router';
+import { useEffect, useRef, useState } from 'react';
+import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
+import { EyeIcon } from '@heroicons/react/24/outline';
+import { Button, Spinner } from '@tih/ui';
+
+import type { BreadcrumbStep } from '~/components/offers/Breadcrumb';
+import { Breadcrumbs } from '~/components/offers/Breadcrumb';
+import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
+import OffersSubmissionAnalysis from '~/components/offers/offersSubmission/OffersSubmissionAnalysis';
+
+import { getProfilePath } from '~/utils/offers/link';
+import { trpc } from '~/utils/trpc';
+
+import type { ProfileAnalysis } from '~/types/offers';
+
+export default function OffersSubmissionResult() {
+ const router = useRouter();
+ let { offerProfileId, token = '' } = router.query;
+ offerProfileId = offerProfileId as string;
+ token = token as string;
+ const [step, setStep] = useState(0);
+ const [analysis, setAnalysis] = useState(null);
+
+ const pageRef = useRef(null);
+ const scrollToTop = () =>
+ pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
+
+ // TODO: Check if the token is valid before showing this page
+ const getAnalysis = trpc.useQuery(
+ ['offers.analysis.get', { profileId: offerProfileId }],
+ {
+ onSuccess(data) {
+ setAnalysis(data);
+ },
+ },
+ );
+
+ const steps = [
+ ,
+ ,
+ ];
+
+ const breadcrumbSteps: Array = [
+ {
+ label: 'Offers',
+ },
+ {
+ label: 'Background',
+ },
+ {
+ label: 'Save profile',
+ step: 0,
+ },
+ {
+ label: 'Analysis',
+ step: 1,
+ },
+ ];
+
+ useEffect(() => {
+ scrollToTop();
+ }, [step]);
+
+ return (
+ <>
+ {getAnalysis.isLoading && (
+
+ )}
+ {!getAnalysis.isLoading && (
+
+
+
+
+
+
+ {steps[step]}
+ {step === 0 && (
+
+
+ )}
+ {step === 1 && (
+
+
+ )}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/portal/src/pages/offers/test/createProfile.tsx b/apps/portal/src/pages/offers/test/createProfile.tsx
index 5f625321..3901505f 100644
--- a/apps/portal/src/pages/offers/test/createProfile.tsx
+++ b/apps/portal/src/pages/offers/test/createProfile.tsx
@@ -16,7 +16,7 @@ function Test() {
});
const addToUserProfileMutation = trpc.useMutation(
- ['offers.profile.addToUserProfile'],
+ ['offers.user.profile.addToUserProfile'],
{
onError(err) {
alert(err);
@@ -85,7 +85,7 @@ function Test() {
addToUserProfileMutation.mutate({
profileId: 'cl9efyn9p004ww3u42mjgl1vn',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
- userId: 'cl9ehvpng0000w3ec2mpx0bdd',
+ // UserId: 'cl9ehvpng0000w3ec2mpx0bdd',
});
};
diff --git a/apps/portal/src/pages/questions/[questionId]/[questionSlug]/answer/[answerId]/[answerSlug]/index.tsx b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/answer/[answerId]/[answerSlug]/index.tsx
index bf136197..81bfaa0c 100644
--- a/apps/portal/src/pages/questions/[questionId]/[questionSlug]/answer/[answerId]/[answerSlug]/index.tsx
+++ b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/answer/[answerId]/[answerSlug]/index.tsx
@@ -42,7 +42,7 @@ export default function QuestionPage() {
]);
const { mutate: addComment } = trpc.useMutation(
- 'questions.answers.comments.create',
+ 'questions.answers.comments.user.create',
{
onSuccess: () => {
utils.invalidateQueries([
diff --git a/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx
index e1239130..c9be44ce 100644
--- a/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx
+++ b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx
@@ -60,7 +60,7 @@ export default function QuestionPage() {
]);
const { mutate: addComment } = trpc.useMutation(
- 'questions.questions.comments.create',
+ 'questions.questions.comments.user.create',
{
onSuccess: () => {
utils.invalidateQueries(
@@ -75,14 +75,17 @@ export default function QuestionPage() {
{ questionId: questionId as string },
]);
- const { mutate: addAnswer } = trpc.useMutation('questions.answers.create', {
- onSuccess: () => {
- utils.invalidateQueries('questions.answers.getAnswers');
+ const { mutate: addAnswer } = trpc.useMutation(
+ 'questions.answers.user.create',
+ {
+ onSuccess: () => {
+ utils.invalidateQueries('questions.answers.getAnswers');
+ },
},
- });
+ );
const { mutate: addEncounter } = trpc.useMutation(
- 'questions.questions.encounters.create',
+ 'questions.questions.encounters.user.create',
{
onSuccess: () => {
utils.invalidateQueries(
diff --git a/apps/portal/src/pages/questions/browse.tsx b/apps/portal/src/pages/questions/browse.tsx
index cb895f27..89ab3c6d 100644
--- a/apps/portal/src/pages/questions/browse.tsx
+++ b/apps/portal/src/pages/questions/browse.tsx
@@ -9,7 +9,6 @@ import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
-import type { FilterOption } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
@@ -184,7 +183,7 @@ export default function QuestionsBrowsePage() {
const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation(
- 'questions.questions.create',
+ 'questions.questions.user.create',
{
onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
@@ -195,18 +194,6 @@ export default function QuestionsBrowsePage() {
const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
- const [selectedCompanyOptions, setSelectedCompanyOptions] = useState<
- Array
- >([]);
-
- const [selectedRoleOptions, setSelectedRoleOptions] = useState<
- Array
- >([]);
-
- const [selectedLocationOptions, setSelectedLocationOptions] = useState<
- Array
- >([]);
-
const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
...questionType,
@@ -275,9 +262,37 @@ export default function QuestionsBrowsePage() {
sortType,
]);
+ const selectedCompanyOptions = useMemo(() => {
+ return selectedCompanies.map((company) => ({
+ checked: true,
+ id: company,
+ label: company,
+ value: company,
+ }));
+ }, [selectedCompanies]);
+
+ const selectedRoleOptions = useMemo(() => {
+ return selectedRoles.map((role) => ({
+ checked: true,
+ id: role,
+ label: role,
+ value: role,
+ }));
+ }, [selectedRoles]);
+
+ const selectedLocationOptions = useMemo(() => {
+ return selectedLocations.map((location) => ({
+ checked: true,
+ id: location,
+ label: location,
+ value: location,
+ }));
+ }, [selectedLocations]);
+
if (!loaded) {
return null;
}
+
const filterSidebar = (
{
- return !selectedCompanyOptions.some((selectedOption) => {
- return selectedOption.value === option.value;
+ return !selectedCompanies.some((company) => {
+ return company === option.value;
});
}}
isLabelHidden={true}
@@ -323,19 +335,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => {
if (option.checked) {
setSelectedCompanies([...selectedCompanies, option.label]);
- setSelectedCompanyOptions((prevOptions) => [
- ...prevOptions,
- { ...option, checked: true },
- ]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== option.label),
);
- setSelectedCompanyOptions((prevOptions) =>
- prevOptions.filter(
- (prevOption) => prevOption.label !== option.label,
- ),
- );
}
}}
/>
@@ -347,8 +350,8 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
- return !selectedRoleOptions.some((selectedOption) => {
- return selectedOption.value === option.value;
+ return !selectedRoles.some((role) => {
+ return role === option.value;
});
}}
isLabelHidden={true}
@@ -364,19 +367,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => {
if (option.checked) {
setSelectedRoles([...selectedRoles, option.value]);
- setSelectedRoleOptions((prevOptions) => [
- ...prevOptions,
- { ...option, checked: true },
- ]);
} else {
setSelectedRoles(
selectedCompanies.filter((role) => role !== option.value),
);
- setSelectedRoleOptions((prevOptions) =>
- prevOptions.filter(
- (prevOption) => prevOption.value !== option.value,
- ),
- );
}
}}
/>
@@ -413,8 +407,8 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
- return !selectedLocationOptions.some((selectedOption) => {
- return selectedOption.value === option.value;
+ return !selectedLocations.some((location) => {
+ return location === option.value;
});
}}
isLabelHidden={true}
@@ -430,19 +424,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => {
if (option.checked) {
setSelectedLocations([...selectedLocations, option.value]);
- setSelectedLocationOptions((prevOptions) => [
- ...prevOptions,
- { ...option, checked: true },
- ]);
} else {
setSelectedLocations(
selectedLocations.filter((role) => role !== option.value),
);
- setSelectedLocationOptions((prevOptions) =>
- prevOptions.filter(
- (prevOption) => prevOption.value !== option.value,
- ),
- );
}
}}
/>
diff --git a/apps/portal/src/pages/questions/lists.tsx b/apps/portal/src/pages/questions/lists.tsx
index 168ada99..ea4009f8 100644
--- a/apps/portal/src/pages/questions/lists.tsx
+++ b/apps/portal/src/pages/questions/lists.tsx
@@ -8,60 +8,90 @@ import {
} from '@heroicons/react/24/outline';
import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
+import type { CreateListFormData } from '~/components/questions/CreateListDialog';
+import CreateListDialog from '~/components/questions/CreateListDialog';
+import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants';
-import { SAMPLE_QUESTION } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
+import { trpc } from '~/utils/trpc';
export default function ListPage() {
- const questions = [
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- SAMPLE_QUESTION,
- ];
+ const utils = trpc.useContext();
+ const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
+ const { mutateAsync: createList } = trpc.useMutation(
+ 'questions.lists.create',
+ {
+ onSuccess: () => {
+ // TODO: Add optimistic update
+ utils.invalidateQueries(['questions.lists.getListsByUser']);
+ },
+ },
+ );
+ const { mutateAsync: deleteList } = trpc.useMutation(
+ 'questions.lists.delete',
+ {
+ onSuccess: () => {
+ // TODO: Add optimistic update
+ utils.invalidateQueries(['questions.lists.getListsByUser']);
+ },
+ },
+ );
+ const { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
+ 'questions.lists.deleteQuestionEntry',
+ {
+ onSuccess: () => {
+ // TODO: Add optimistic update
+ utils.invalidateQueries(['questions.lists.getListsByUser']);
+ },
+ },
+ );
- const lists = [
- { id: 1, name: 'list 1', questions },
- { id: 2, name: 'list 2', questions },
- { id: 3, name: 'list 3', questions },
- { id: 4, name: 'list 4', questions },
- { id: 5, name: 'list 5', questions },
- ];
+ const [selectedListIndex, setSelectedListIndex] = useState(0);
+ const [showDeleteListDialog, setShowDeleteListDialog] = useState(false);
+ const [showCreateListDialog, setShowCreateListDialog] = useState(false);
+
+ const [listIdToDelete, setListIdToDelete] = useState('');
+
+ const handleDeleteList = async (listId: string) => {
+ await deleteList({
+ id: listId,
+ });
+ setShowDeleteListDialog(false);
+ };
+
+ const handleDeleteListCancel = () => {
+ setShowDeleteListDialog(false);
+ };
+
+ const handleCreateList = async (data: CreateListFormData) => {
+ await createList({
+ name: data.name,
+ });
+ setShowCreateListDialog(false);
+ };
+
+ const handleCreateListCancel = () => {
+ setShowCreateListDialog(false);
+ };
- const [selectedList, setSelectedList] = useState(
- (lists ?? []).length > 0 ? lists[0].id : '',
- );
const listOptions = (
<>
- {lists.map((list) => (
+ {(lists ?? []).map((list, index) => (
-
{
- setSelectedList(list.id);
- // eslint-disable-next-line no-console
- console.log(selectedList);
+ setSelectedListIndex(index);
}}>
-
+
{list.name}
@@ -85,7 +115,11 @@ export default function ListPage() {
? 'bg-violet-500 text-white'
: 'text-slate-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
- type="button">
+ type="button"
+ onClick={() => {
+ setShowDeleteListDialog(true);
+ setListIdToDelete(list.id);
+ }}>
Delete
)}
@@ -104,6 +138,7 @@ export default function ListPage() {
)}
>
);
+
return (
<>
@@ -111,7 +146,7 @@ export default function ListPage() {
-
@@ -199,21 +290,54 @@ export default function ResumeReviewPage() {
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
- {detailsQuery.data.role}
+
+ onInfoTagClick({
+ roleLabel: detailsQuery.data?.role,
+ })
+ }>
+ {getFilterLabel(ROLES, detailsQuery.data.role as RoleFilter)}
+
- {detailsQuery.data.location}
+
+ onInfoTagClick({
+ locationLabel: detailsQuery.data?.location,
+ })
+ }>
+ {getFilterLabel(
+ LOCATIONS,
+ detailsQuery.data.location as LocationFilter,
+ )}
+
- {detailsQuery.data.experience}
+
+ onInfoTagClick({
+ experienceLabel: detailsQuery.data?.experience,
+ })
+ }>
+ {getFilterLabel(
+ EXPERIENCES,
+ detailsQuery.data.experience as ExperienceFilter,
+ )}
+
-
-
-
+
+ {renderReviewButton()}
+
+
Reviews
+
-
{renderReviewButton()}
-
{showCommentsForm ? (
+
+ Resume Review Portal
+
+
+
+
+ Resume reviews{' '}
+
+
+ made simple
+
+
+
+
+ {/* About Us Section */}
+
+ About Us 🤓
+
+
+
+ As you apply for your dream jobs or internships, have you ever felt
+ unsure about your resume? Have you wondered about how others got past
+ resume screening in a breeze? Wonder no more!
+
+
+
+ Tech Interview Handbook's very own Resume Review portal is here to help!
+ Simply submit your resume and collect invaluable feedback from our
+ community of Software Engineers, Hiring Managers and so many more...
+
+
+ {/* Feedback */}
+
+ Feedback? New Features? BUGS?! 😱
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/pages/resumes/browse.tsx b/apps/portal/src/pages/resumes/browse.tsx
index c7b4fe93..de2b13c6 100644
--- a/apps/portal/src/pages/resumes/browse.tsx
+++ b/apps/portal/src/pages/resumes/browse.tsx
@@ -1,7 +1,7 @@
import Head from 'next/head';
-import { useRouter } from 'next/router';
+import Router, { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
-import { Fragment, useEffect, useState } from 'react';
+import { Fragment, useEffect, useMemo, useState } from 'react';
import { Dialog, Disclosure, Transition } from '@headlessui/react';
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import {
@@ -10,6 +10,7 @@ import {
XMarkIcon,
} from '@heroicons/react/24/outline';
import {
+ Button,
CheckboxInput,
CheckboxList,
DropdownMenu,
@@ -20,28 +21,31 @@ import {
} from '@tih/ui';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
+import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
+import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
+
import type {
Filter,
FilterId,
+ FilterLabel,
Shortcut,
-} from '~/components/resumes/browse/resumeFilters';
+} from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
+ getFilterLabel,
INITIAL_FILTER_STATE,
isInitialFilterState,
LOCATIONS,
ROLES,
SHORTCUTS,
SORT_OPTIONS,
-} from '~/components/resumes/browse/resumeFilters';
-import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
-import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
-
+} from '~/utils/resumes/resumeFilters';
import useDebounceValue from '~/utils/resumes/useDebounceValue';
+import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc';
-import type { FilterState } from '../../components/resumes/browse/resumeFilters';
+import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
@@ -101,19 +105,89 @@ const getEmptyDataText = (
export default function ResumeHomePage() {
const { data: sessionData } = useSession();
const router = useRouter();
- const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
- const [sortOrder, setSortOrder] = useState('latest');
- const [searchValue, setSearchValue] = useState('');
- const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
- const [shortcutSelected, setShortcutSelected] = useState('All');
- const [currentPage, setCurrentPage] = useState(1);
+ const [tabsValue, setTabsValue, isTabsValueInit] = useSearchParams(
+ 'tabsValue',
+ BROWSE_TABS_VALUES.ALL,
+ );
+ const [sortOrder, setSortOrder, isSortOrderInit] = useSearchParams
(
+ 'sortOrder',
+ 'latest',
+ );
+ const [searchValue, setSearchValue, isSearchValueInit] = useSearchParams(
+ 'searchValue',
+ '',
+ );
+ const [shortcutSelected, setShortcutSelected, isShortcutInit] =
+ useSearchParams('shortcutSelected', 'All');
+ const [currentPage, setCurrentPage, isCurrentPageInit] = useSearchParams(
+ 'currentPage',
+ 1,
+ );
+ const [userFilters, setUserFilters, isUserFiltersInit] = useSearchParams(
+ 'userFilters',
+ INITIAL_FILTER_STATE,
+ );
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const skip = (currentPage - 1) * PAGE_LIMIT;
+ const isSearchOptionsInit = useMemo(() => {
+ return (
+ isTabsValueInit &&
+ isSortOrderInit &&
+ isSearchValueInit &&
+ isShortcutInit &&
+ isCurrentPageInit &&
+ isUserFiltersInit
+ );
+ }, [
+ isTabsValueInit,
+ isSortOrderInit,
+ isSearchValueInit,
+ isShortcutInit,
+ isCurrentPageInit,
+ isUserFiltersInit,
+ ]);
useEffect(() => {
setCurrentPage(1);
- }, [userFilters, sortOrder, searchValue]);
+ }, [userFilters, sortOrder, setCurrentPage, searchValue]);
+
+ useEffect(() => {
+ // Router.replace used instead of router.replace to avoid
+ // the page reloading itself since the router.replace
+ // callback changes on every page load
+ if (!isSearchOptionsInit) {
+ return;
+ }
+
+ Router.replace({
+ pathname: router.pathname,
+ query: {
+ currentPage: JSON.stringify(currentPage),
+ searchValue: JSON.stringify(searchValue),
+ shortcutSelected: JSON.stringify(shortcutSelected),
+ sortOrder: JSON.stringify(sortOrder),
+ tabsValue: JSON.stringify(tabsValue),
+ userFilters: JSON.stringify(userFilters),
+ },
+ });
+ }, [
+ tabsValue,
+ sortOrder,
+ searchValue,
+ userFilters,
+ shortcutSelected,
+ currentPage,
+ router.pathname,
+ isSearchOptionsInit,
+ ]);
+
+ const filterCountsQuery = trpc.useQuery(
+ ['resumes.resume.getTotalFilterCounts'],
+ {
+ staleTime: STALE_TIME,
+ },
+ );
const allResumesQuery = trpc.useQuery(
[
@@ -175,6 +249,14 @@ export default function ResumeHomePage() {
},
);
+ const getFilterCount = (filter: FilterLabel, value: string) => {
+ if (filterCountsQuery.isLoading) {
+ return 0;
+ }
+ const filterCountsData = filterCountsQuery.data!;
+ return filterCountsData[filter][value];
+ };
+
const onSubmitResume = () => {
if (sessionData === null) {
router.push('/api/auth/signin');
@@ -203,6 +285,13 @@ export default function ResumeHomePage() {
}
};
+ const onClearFilterClick = (filterSection: FilterId) => {
+ setUserFilters({
+ ...userFilters,
+ [filterSection]: [],
+ });
+ };
+
const onShortcutChange = ({
sortOrder: shortcutSortOrder,
filters: shortcutFilters,
@@ -283,7 +372,7 @@ export default function ResumeHomePage() {
- Shortcuts
+ Quick access
{SHORTCUTS.map((shortcut) => (
-
@@ -313,7 +402,7 @@ export default function ResumeHomePage() {
+ className="border-t border-slate-200 px-4 pt-6 pb-4">
{({ open }) => (
<>
@@ -336,14 +425,17 @@ export default function ResumeHomePage() {
-
-
+
+
{filter.options.map((option) => (
))}
+
onClearFilterClick(filter.id)}>
+ Clear
+
>
)}
@@ -371,11 +468,12 @@ export default function ResumeHomePage() {
-
+
+ {/* Quick Access Section */}
- Shortcuts
+ Quick access
-
-
+
+
-
+
-
-
- {Object.entries(SORT_OPTIONS).map(([key, value]) => (
- setSortOrder(key)}>
- ))}
-
-
-
setMobileFiltersOpen(true)}>
- Filters
-
-
-
-
- Submit Resume
-
-
+
+ {SORT_OPTIONS.map(({ label, value }) => (
+ setSortOrder(value)}>
+ ))}
+
+
setMobileFiltersOpen(true)}
+ />
+
{isFetchingResumes ? (
diff --git a/apps/portal/src/pages/resumes/index.tsx b/apps/portal/src/pages/resumes/index.tsx
index ae063c0b..a7af72aa 100644
--- a/apps/portal/src/pages/resumes/index.tsx
+++ b/apps/portal/src/pages/resumes/index.tsx
@@ -11,7 +11,7 @@ export default function Home() {
Resume Review
-
+
diff --git a/apps/portal/src/pages/resumes/submit.tsx b/apps/portal/src/pages/resumes/submit.tsx
index 7360c9de..61bb7cc5 100644
--- a/apps/portal/src/pages/resumes/submit.tsx
+++ b/apps/portal/src/pages/resumes/submit.tsx
@@ -19,14 +19,10 @@ import {
TextInput,
} from '@tih/ui';
-import {
- EXPERIENCES,
- LOCATIONS,
- ROLES,
-} from '~/components/resumes/browse/resumeFilters';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
+import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc';
const FILE_SIZE_LIMIT_MB = 3;
@@ -172,6 +168,9 @@ export default function SubmitResumeForm({
onSuccess() {
if (isNewForm) {
trpcContext.invalidateQueries('resumes.resume.findAll');
+ trpcContext.invalidateQueries(
+ 'resumes.resume.getTotalFilterCounts',
+ );
router.push('/resumes/browse');
} else {
onClose();
diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts
index 99e32b83..41a01d5d 100644
--- a/apps/portal/src/server/router/index.ts
+++ b/apps/portal/src/server/router/index.ts
@@ -6,12 +6,19 @@ import { offersRouter } from './offers/offers';
import { offersAnalysisRouter } from './offers/offers-analysis-router';
import { offersCommentsRouter } from './offers/offers-comments-router';
import { offersProfileRouter } from './offers/offers-profile-router';
+import { offersUserProfileRouter } from './offers/offers-user-profile-router';
import { protectedExampleRouter } from './protected-example-router';
-import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
-import { questionsAnswerRouter } from './questions-answer-router';
-import { questionsQuestionCommentRouter } from './questions-question-comment-router';
-import { questionsQuestionEncounterRouter } from './questions-question-encounter-router';
-import { questionsQuestionRouter } from './questions-question-router';
+import { questionsAnswerCommentRouter } from './questions/questions-answer-comment-router';
+import { questionsAnswerCommentUserRouter } from './questions/questions-answer-comment-user-router';
+import { questionsAnswerRouter } from './questions/questions-answer-router';
+import { questionsAnswerUserRouter } from './questions/questions-answer-user-router';
+import { questionsListRouter } from './questions/questions-list-router';
+import { questionsQuestionCommentRouter } from './questions/questions-question-comment-router';
+import { questionsQuestionCommentUserRouter } from './questions/questions-question-comment-user-router';
+import { questionsQuestionEncounterRouter } from './questions/questions-question-encounter-router';
+import { questionsQuestionEncounterUserRouter } from './questions/questions-question-encounter-user-router';
+import { questionsQuestionRouter } from './questions/questions-question-router';
+import { questionsQuestionUserRouter } from './questions/questions-question-user-router';
import { resumeCommentsRouter } from './resumes/resumes-comments-router';
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
import { resumesCommentsVotesRouter } from './resumes/resumes-comments-votes-router';
@@ -39,14 +46,27 @@ export const appRouter = createRouter()
.merge('resumes.comments.votes.', resumesCommentsVotesRouter)
.merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
+ .merge('questions.answers.comments.user.', questionsAnswerCommentUserRouter)
.merge('questions.answers.', questionsAnswerRouter)
+ .merge('questions.answers.user.', questionsAnswerUserRouter)
+ .merge('questions.lists.', questionsListRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
+ .merge(
+ 'questions.questions.comments.user.',
+ questionsQuestionCommentUserRouter,
+ )
.merge('questions.questions.encounters.', questionsQuestionEncounterRouter)
+ .merge(
+ 'questions.questions.encounters.user.',
+ questionsQuestionEncounterUserRouter,
+ )
.merge('questions.questions.', questionsQuestionRouter)
+ .merge('questions.questions.user.', questionsQuestionUserRouter)
.merge('offers.', offersRouter)
.merge('offers.profile.', offersProfileRouter)
.merge('offers.analysis.', offersAnalysisRouter)
- .merge('offers.comments.', offersCommentsRouter);
+ .merge('offers.comments.', offersCommentsRouter)
+ .merge('offers.user.profile.', offersUserProfileRouter);
// Export type definition of API
export type AppRouter = typeof appRouter;
diff --git a/apps/portal/src/server/router/offers/offers-profile-router.ts b/apps/portal/src/server/router/offers/offers-profile-router.ts
index f71d9c48..1e1d1d52 100644
--- a/apps/portal/src/server/router/offers/offers-profile-router.ts
+++ b/apps/portal/src/server/router/offers/offers-profile-router.ts
@@ -4,7 +4,6 @@ import { JobType } from '@prisma/client';
import * as trpc from '@trpc/server';
import {
- addToProfileResponseMapper,
createOfferProfileResponseMapper,
profileDtoMapper,
} from '~/mappers/offers-mappers';
@@ -107,6 +106,7 @@ export const offersProfileRouter = createRouter()
input: z.object({
profileId: z.string(),
token: z.string().optional(),
+ userId: z.string().nullish(),
}),
async resolve({ ctx, input }) {
const result = await ctx.prisma.offersProfile.findFirst({
@@ -229,6 +229,7 @@ export const offersProfileRouter = createRouter()
},
},
},
+ user: true,
},
where: {
id: input.profileId,
@@ -236,7 +237,7 @@ export const offersProfileRouter = createRouter()
});
if (result) {
- return profileDtoMapper(result, input.token);
+ return profileDtoMapper(result, input.token, input.userId);
}
throw new trpc.TRPCError({
@@ -284,18 +285,42 @@ export const offersProfileRouter = createRouter()
})),
},
experiences: {
- create: input.background.experiences.map(async (x) => {
- if (x.jobType === JobType.FULLTIME) {
- if (x.companyId) {
- return {
- company: {
- connect: {
- id: x.companyId,
+ create: await Promise.all(
+ input.background.experiences.map(async (x) => {
+ if (x.jobType === JobType.FULLTIME) {
+ if (x.companyId) {
+ return {
+ company: {
+ connect: {
+ id: x.companyId,
+ },
},
- },
+ durationInMonths: x.durationInMonths,
+ jobType: x.jobType,
+ level: x.level,
+ title: x.title,
+ totalCompensation:
+ x.totalCompensation != null
+ ? {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.totalCompensation.value,
+ x.totalCompensation.currency,
+ baseCurrencyString,
+ ),
+ currency: x.totalCompensation.currency,
+ value: x.totalCompensation.value,
+ },
+ }
+ : undefined,
+ };
+ }
+ return {
durationInMonths: x.durationInMonths,
jobType: x.jobType,
level: x.level,
+ location: x.location,
title: x.title,
totalCompensation:
x.totalCompensation != null
@@ -314,37 +339,35 @@ export const offersProfileRouter = createRouter()
: undefined,
};
}
- return {
- durationInMonths: x.durationInMonths,
- jobType: x.jobType,
- level: x.level,
- location: x.location,
- title: x.title,
- totalCompensation:
- x.totalCompensation != null
- ? {
- create: {
- baseCurrency: baseCurrencyString,
- baseValue: await convert(
- x.totalCompensation.value,
- x.totalCompensation.currency,
- baseCurrencyString,
- ),
- currency: x.totalCompensation.currency,
- value: x.totalCompensation.value,
- },
- }
- : undefined,
- };
- }
- if (x.jobType === JobType.INTERN) {
- if (x.companyId) {
- return {
- company: {
- connect: {
- id: x.companyId,
+ if (x.jobType === JobType.INTERN) {
+ if (x.companyId) {
+ return {
+ company: {
+ connect: {
+ id: x.companyId,
+ },
},
- },
+ durationInMonths: x.durationInMonths,
+ jobType: x.jobType,
+ monthlySalary:
+ x.monthlySalary != null
+ ? {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.monthlySalary.value,
+ x.monthlySalary.currency,
+ baseCurrencyString,
+ ),
+ currency: x.monthlySalary.currency,
+ value: x.monthlySalary.value,
+ },
+ }
+ : undefined,
+ title: x.title,
+ };
+ }
+ return {
durationInMonths: x.durationInMonths,
jobType: x.jobType,
monthlySalary:
@@ -365,33 +388,13 @@ export const offersProfileRouter = createRouter()
title: x.title,
};
}
- return {
- durationInMonths: x.durationInMonths,
- jobType: x.jobType,
- monthlySalary:
- x.monthlySalary != null
- ? {
- create: {
- baseCurrency: baseCurrencyString,
- baseValue: await convert(
- x.monthlySalary.value,
- x.monthlySalary.currency,
- baseCurrencyString,
- ),
- currency: x.monthlySalary.currency,
- value: x.monthlySalary.value,
- },
- }
- : undefined,
- title: x.title,
- };
- }
- throw new trpc.TRPCError({
- code: 'BAD_REQUEST',
- message: 'Missing fields in background experiences.',
- });
- }),
+ throw new trpc.TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Missing fields in background experiences.',
+ });
+ }),
+ )
},
specificYoes: {
create: input.background.specificYoes.map((x) => {
@@ -546,7 +549,6 @@ export const offersProfileRouter = createRouter()
profileName: uniqueName,
},
});
-
return createOfferProfileResponseMapper(profile, token);
},
})
@@ -1406,44 +1408,6 @@ export const offersProfileRouter = createRouter()
});
}
- throw new trpc.TRPCError({
- code: 'UNAUTHORIZED',
- message: 'Invalid token.',
- });
- },
- })
- .mutation('addToUserProfile', {
- input: z.object({
- profileId: z.string(),
- token: z.string(),
- userId: z.string(),
- }),
- async resolve({ ctx, input }) {
- const profile = await ctx.prisma.offersProfile.findFirst({
- where: {
- id: input.profileId,
- },
- });
-
- const profileEditToken = profile?.editToken;
-
- if (profileEditToken === input.token) {
- const updated = await ctx.prisma.offersProfile.update({
- data: {
- user: {
- connect: {
- id: input.userId,
- },
- },
- },
- where: {
- id: input.profileId,
- },
- });
-
- return addToProfileResponseMapper(updated);
- }
-
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid token.',
diff --git a/apps/portal/src/server/router/offers/offers-user-profile-router.ts b/apps/portal/src/server/router/offers/offers-user-profile-router.ts
new file mode 100644
index 00000000..d0468abb
--- /dev/null
+++ b/apps/portal/src/server/router/offers/offers-user-profile-router.ts
@@ -0,0 +1,131 @@
+import { z } from 'zod';
+import * as trpc from '@trpc/server';
+import { TRPCError } from '@trpc/server';
+
+import {
+ addToProfileResponseMapper, getUserProfileResponseMapper,
+} from '~/mappers/offers-mappers';
+
+import { createProtectedRouter } from '../context';
+
+export const offersUserProfileRouter = createProtectedRouter()
+ .mutation('addToUserProfile', {
+ input: z.object({
+ profileId: z.string(),
+ token: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ const profileEditToken = profile?.editToken;
+ if (profileEditToken === input.token) {
+
+ const userId = ctx.session.user.id
+ const updated = await ctx.prisma.offersProfile.update({
+ data: {
+ user: {
+ connect: {
+ id: userId,
+ },
+ },
+ },
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ return addToProfileResponseMapper(updated);
+ }
+
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Invalid token.',
+ });
+ },
+ })
+ .mutation('getUserProfiles', {
+ async resolve({ ctx }) {
+ const userId = ctx.session.user.id
+ const result = await ctx.prisma.user.findFirst({
+ include: {
+ OffersProfile: {
+ include: {
+ offers: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true
+ }
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ where: {
+ id: userId
+ }
+ })
+
+ return getUserProfileResponseMapper(result)
+ }
+ })
+ .mutation('removeFromUserProfile', {
+ input: z.object({
+ profileId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session.user.id
+
+ const profiles = await ctx.prisma.user.findFirst({
+ include: {
+ OffersProfile: true
+ },
+ where: {
+ id: userId
+ }
+ })
+
+ // Validation
+ let doesProfileExist = false;
+
+ if (profiles?.OffersProfile) {
+ for (let i = 0; i < profiles.OffersProfile.length; i++) {
+ if (profiles.OffersProfile[i].id === input.profileId) {
+ doesProfileExist = true
+ }
+ }
+ }
+
+ if (!doesProfileExist) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'No such profile id saved.'
+ })
+ }
+
+ await ctx.prisma.user.update({
+ data: {
+ OffersProfile: {
+ disconnect: [{
+ id: input.profileId
+ }]
+ }
+ },
+ where: {
+ id: userId
+ }
+ })
+
+ }
+ })
\ No newline at end of file
diff --git a/apps/portal/src/server/router/questions-list-crud.ts b/apps/portal/src/server/router/questions-list-router.ts
similarity index 55%
rename from apps/portal/src/server/router/questions-list-crud.ts
rename to apps/portal/src/server/router/questions-list-router.ts
index 1f375497..3187c914 100644
--- a/apps/portal/src/server/router/questions-list-crud.ts
+++ b/apps/portal/src/server/router/questions-list-router.ts
@@ -1,53 +1,129 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
+import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
+
import { createProtectedRouter } from './context';
-export const questionListRouter = createProtectedRouter()
+export const questionsListRouter = createProtectedRouter()
.query('getListsByUser', {
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
- return await ctx.prisma.questionsList.findMany({
+ // TODO: Optimize by not returning question entries
+ const questionsLists = await ctx.prisma.questionsList.findMany({
include: {
questionEntries: {
include: {
- question: true,
+ question: {
+ include: {
+ _count: {
+ select: {
+ answers: true,
+ comments: true,
+ },
+ },
+ encounters: {
+ select: {
+ company: true,
+ location: true,
+ role: true,
+ seenAt: true,
+ },
+ },
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ },
},
- }
+ },
},
orderBy: {
createdAt: 'asc',
},
where: {
- id: userId,
+ userId,
},
});
- }
+
+ const lists = questionsLists.map((list) => ({
+ ...list,
+ questionEntries: list.questionEntries.map((entry) => ({
+ ...entry,
+ question: createQuestionWithAggregateData(entry.question),
+ })),
+ }));
+
+ return lists;
+ },
})
.query('getListById', {
input: z.object({
listId: z.string(),
}),
- async resolve({ ctx }) {
+ async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { listId } = input;
- return await ctx.prisma.questionsList.findMany({
+ const questionList = await ctx.prisma.questionsList.findFirst({
include: {
questionEntries: {
include: {
- question: true,
+ question: {
+ include: {
+ _count: {
+ select: {
+ answers: true,
+ comments: true,
+ },
+ },
+ encounters: {
+ select: {
+ company: true,
+ location: true,
+ role: true,
+ seenAt: true,
+ },
+ },
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ },
},
- }
+ },
},
orderBy: {
createdAt: 'asc',
},
where: {
- id: userId,
+ id: listId,
+ userId,
},
});
- }
+
+ if (!questionList) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Question list not found',
+ });
+ }
+
+ return {
+ ...questionList,
+ questionEntries: questionList.questionEntries.map((questionEntry) => ({
+ ...questionEntry,
+ question: createQuestionWithAggregateData(questionEntry.question),
+ })),
+ };
+ },
})
.mutation('create', {
input: z.object({
@@ -111,7 +187,7 @@ export const questionListRouter = createProtectedRouter()
},
});
- if (listToDelete?.id !== userId) {
+ if (listToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@@ -139,7 +215,7 @@ export const questionListRouter = createProtectedRouter()
},
});
- if (listToAugment?.id !== userId) {
+ if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@@ -163,27 +239,27 @@ export const questionListRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
- const entryToDelete = await ctx.prisma.questionsListQuestionEntry.findUnique({
- where: {
- id: input.id,
- },
- });
+ const entryToDelete =
+ await ctx.prisma.questionsListQuestionEntry.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
- if (entryToDelete?.id !== userId) {
+ if (entryToDelete === null) {
throw new TRPCError({
- code: 'UNAUTHORIZED',
- message: 'User have no authorization to record.',
+ code: 'NOT_FOUND',
+ message: 'Entry not found.',
});
}
-
const listToAugment = await ctx.prisma.questionsList.findUnique({
where: {
id: entryToDelete.listId,
},
});
- if (listToAugment?.id !== userId) {
+ if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
diff --git a/apps/portal/src/server/router/questions-question-encounter-router.ts b/apps/portal/src/server/router/questions-question-encounter-router.ts
deleted file mode 100644
index 8fa4a0e4..00000000
--- a/apps/portal/src/server/router/questions-question-encounter-router.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import { z } from 'zod';
-import { TRPCError } from '@trpc/server';
-
-import { createProtectedRouter } from './context';
-
-import type { AggregatedQuestionEncounter } from '~/types/questions';
-
-export const questionsQuestionEncounterRouter = createProtectedRouter()
- .query('getAggregatedEncounters', {
- input: z.object({
- questionId: z.string(),
- }),
- async resolve({ ctx, input }) {
- const questionEncountersData =
- await ctx.prisma.questionsQuestionEncounter.findMany({
- include: {
- company: true,
- },
- where: {
- ...input,
- },
- });
-
- const companyCounts: Record = {};
- const locationCounts: Record = {};
- const roleCounts: Record = {};
-
- let latestSeenAt = questionEncountersData[0].seenAt;
-
- for (let i = 0; i < questionEncountersData.length; i++) {
- const encounter = questionEncountersData[i];
-
- latestSeenAt = latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
-
- if (!(encounter.company!.name in companyCounts)) {
- companyCounts[encounter.company!.name] = 1;
- }
- companyCounts[encounter.company!.name] += 1;
-
- if (!(encounter.location in locationCounts)) {
- locationCounts[encounter.location] = 1;
- }
- locationCounts[encounter.location] += 1;
-
- if (!(encounter.role in roleCounts)) {
- roleCounts[encounter.role] = 1;
- }
- roleCounts[encounter.role] += 1;
- }
-
- const questionEncounter: AggregatedQuestionEncounter = {
- companyCounts,
- latestSeenAt,
- locationCounts,
- roleCounts,
- };
- return questionEncounter;
- },
- })
- .mutation('create', {
- input: z.object({
- companyId: z.string(),
- location: z.string(),
- questionId: z.string(),
- role: z.string(),
- seenAt: z.date(),
- }),
- async resolve({ ctx, input }) {
- const userId = ctx.session?.user?.id;
-
- return await ctx.prisma.questionsQuestionEncounter.create({
- data: {
- ...input,
- userId,
- },
- });
- },
- })
- .mutation('update', {
- input: z.object({
- companyId: z.string().optional(),
- id: z.string(),
- location: z.string().optional(),
- role: z.string().optional(),
- seenAt: z.date().optional(),
- }),
- async resolve({ ctx, input }) {
- const userId = ctx.session?.user?.id;
-
- const questionEncounterToUpdate =
- await ctx.prisma.questionsQuestionEncounter.findUnique({
- where: {
- id: input.id,
- },
- });
-
- if (questionEncounterToUpdate?.id !== userId) {
- throw new TRPCError({
- code: 'UNAUTHORIZED',
- message: 'User have no authorization to record.',
- });
- }
-
- return await ctx.prisma.questionsQuestionEncounter.update({
- data: {
- ...input,
- },
- where: {
- id: input.id,
- },
- });
- },
- })
- .mutation('delete', {
- input: z.object({
- id: z.string(),
- }),
- async resolve({ ctx, input }) {
- const userId = ctx.session?.user?.id;
-
- const questionEncounterToDelete =
- await ctx.prisma.questionsQuestionEncounter.findUnique({
- where: {
- id: input.id,
- },
- });
-
- if (questionEncounterToDelete?.id !== userId) {
- throw new TRPCError({
- code: 'UNAUTHORIZED',
- message: 'User have no authorization to record.',
- });
- }
-
- return await ctx.prisma.questionsQuestionEncounter.delete({
- where: {
- id: input.id,
- },
- });
- },
- });
diff --git a/apps/portal/src/server/router/questions-question-router.ts b/apps/portal/src/server/router/questions-question-router.ts
index 3ea33bce..b0c02981 100644
--- a/apps/portal/src/server/router/questions-question-router.ts
+++ b/apps/portal/src/server/router/questions-question-router.ts
@@ -2,9 +2,10 @@ import { z } from 'zod';
import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
+import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
+
import { createProtectedRouter } from './context';
-import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createProtectedRouter()
@@ -122,72 +123,9 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
- const processedQuestionsData = questionsData.map((data) => {
- const votes: number = data.votes.reduce(
- (previousValue: number, currentValue) => {
- let result: number = previousValue;
-
- switch (currentValue.vote) {
- case Vote.UPVOTE:
- result += 1;
- break;
- case Vote.DOWNVOTE:
- result -= 1;
- break;
- }
- return result;
- },
- 0,
- );
-
- const companyCounts: Record = {};
- const locationCounts: Record = {};
- const roleCounts: Record = {};
-
- let latestSeenAt = data.encounters[0].seenAt;
-
- for (let i = 0; i < data.encounters.length; i++) {
- const encounter = data.encounters[i];
-
- latestSeenAt =
- latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
-
- if (!(encounter.company!.name in companyCounts)) {
- companyCounts[encounter.company!.name] = 1;
- }
- companyCounts[encounter.company!.name] += 1;
-
- if (!(encounter.location in locationCounts)) {
- locationCounts[encounter.location] = 1;
- }
- locationCounts[encounter.location] += 1;
-
- if (!(encounter.role in roleCounts)) {
- roleCounts[encounter.role] = 1;
- }
- roleCounts[encounter.role] += 1;
- }
-
- const question: Question = {
- aggregatedQuestionEncounters: {
- companyCounts,
- latestSeenAt,
- locationCounts,
- roleCounts,
- },
- content: data.content,
- id: data.id,
- numAnswers: data._count.answers,
- numComments: data._count.comments,
- numVotes: votes,
- receivedCount: data.encounters.length,
- seenAt: latestSeenAt,
- type: data.questionType,
- updatedAt: data.updatedAt,
- user: data.user?.name ?? '',
- };
- return question;
- });
+ const processedQuestionsData = questionsData.map(
+ createQuestionWithAggregateData,
+ );
let nextCursor: typeof cursor | undefined = undefined;
@@ -252,68 +190,8 @@ export const questionsQuestionRouter = createProtectedRouter()
message: 'Question not found',
});
}
- const votes: number = questionData.votes.reduce(
- (previousValue: number, currentValue) => {
- let result: number = previousValue;
-
- switch (currentValue.vote) {
- case Vote.UPVOTE:
- result += 1;
- break;
- case Vote.DOWNVOTE:
- result -= 1;
- break;
- }
- return result;
- },
- 0,
- );
-
- const companyCounts: Record = {};
- const locationCounts: Record = {};
- const roleCounts: Record = {};
- let latestSeenAt = questionData.encounters[0].seenAt;
-
- for (const encounter of questionData.encounters) {
- latestSeenAt =
- latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
-
- if (!(encounter.company!.name in companyCounts)) {
- companyCounts[encounter.company!.name] = 1;
- }
- companyCounts[encounter.company!.name] += 1;
-
- if (!(encounter.location in locationCounts)) {
- locationCounts[encounter.location] = 1;
- }
- locationCounts[encounter.location] += 1;
-
- if (!(encounter.role in roleCounts)) {
- roleCounts[encounter.role] = 1;
- }
- roleCounts[encounter.role] += 1;
- }
-
- const question: Question = {
- aggregatedQuestionEncounters: {
- companyCounts,
- latestSeenAt,
- locationCounts,
- roleCounts,
- },
- content: questionData.content,
- id: questionData.id,
- numAnswers: questionData._count.answers,
- numComments: questionData._count.comments,
- numVotes: votes,
- receivedCount: questionData.encounters.length,
- seenAt: questionData.encounters[0].seenAt,
- type: questionData.questionType,
- updatedAt: questionData.updatedAt,
- user: questionData.user?.name ?? '',
- };
- return question;
+ return createQuestionWithAggregateData(questionData);
},
})
.mutation('create', {
diff --git a/apps/portal/src/server/router/questions/questions-answer-comment-router.ts b/apps/portal/src/server/router/questions/questions-answer-comment-router.ts
new file mode 100644
index 00000000..2ecd51b7
--- /dev/null
+++ b/apps/portal/src/server/router/questions/questions-answer-comment-router.ts
@@ -0,0 +1,64 @@
+import { z } from 'zod';
+import { Vote } from '@prisma/client';
+
+import { createRouter } from '../context';
+
+import type { AnswerComment } from '~/types/questions';
+
+export const questionsAnswerCommentRouter = createRouter().query(
+ 'getAnswerComments',
+ {
+ input: z.object({
+ answerId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const questionAnswerCommentsData =
+ await ctx.prisma.questionsAnswerComment.findMany({
+ include: {
+ user: {
+ select: {
+ image: true,
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ where: {
+ answerId: input.answerId,
+ },
+ });
+ return questionAnswerCommentsData.map((data) => {
+ const votes: number = data.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const answerComment: AnswerComment = {
+ content: data.content,
+ createdAt: data.createdAt,
+ id: data.id,
+ numVotes: votes,
+ updatedAt: data.updatedAt,
+ user: data.user?.name ?? '',
+ userImage: data.user?.image ?? '',
+ };
+ return answerComment;
+ });
+ },
+ },
+);
diff --git a/apps/portal/src/server/router/questions-answer-comment-router.ts b/apps/portal/src/server/router/questions/questions-answer-comment-user-router.ts
similarity index 77%
rename from apps/portal/src/server/router/questions-answer-comment-router.ts
rename to apps/portal/src/server/router/questions/questions-answer-comment-user-router.ts
index 63a9ede4..d55e590c 100644
--- a/apps/portal/src/server/router/questions-answer-comment-router.ts
+++ b/apps/portal/src/server/router/questions/questions-answer-comment-user-router.ts
@@ -2,65 +2,9 @@ import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
-import { createProtectedRouter } from './context';
+import { createProtectedRouter } from '../context';
-import type { AnswerComment } from '~/types/questions';
-
-export const questionsAnswerCommentRouter = createProtectedRouter()
- .query('getAnswerComments', {
- input: z.object({
- answerId: z.string(),
- }),
- async resolve({ ctx, input }) {
- const questionAnswerCommentsData =
- await ctx.prisma.questionsAnswerComment.findMany({
- include: {
- user: {
- select: {
- image: true,
- name: true,
- },
- },
- votes: true,
- },
- orderBy: {
- createdAt: 'desc',
- },
- where: {
- answerId: input.answerId,
- },
- });
- return questionAnswerCommentsData.map((data) => {
- const votes: number = data.votes.reduce(
- (previousValue: number, currentValue) => {
- let result: number = previousValue;
-
- switch (currentValue.vote) {
- case Vote.UPVOTE:
- result += 1;
- break;
- case Vote.DOWNVOTE:
- result -= 1;
- break;
- }
- return result;
- },
- 0,
- );
-
- const answerComment: AnswerComment = {
- content: data.content,
- createdAt: data.createdAt,
- id: data.id,
- numVotes: votes,
- updatedAt: data.updatedAt,
- user: data.user?.name ?? '',
- userImage: data.user?.image ?? '',
- };
- return answerComment;
- });
- },
- })
+export const questionsAnswerCommentUserRouter = createProtectedRouter()
.mutation('create', {
input: z.object({
answerId: z.string(),
@@ -281,6 +225,5 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}),
]);
return answerCommentVote;
-
},
});
diff --git a/apps/portal/src/server/router/questions/questions-answer-router.ts b/apps/portal/src/server/router/questions/questions-answer-router.ts
new file mode 100644
index 00000000..bdc5ca31
--- /dev/null
+++ b/apps/portal/src/server/router/questions/questions-answer-router.ts
@@ -0,0 +1,128 @@
+import { z } from 'zod';
+import { Vote } from '@prisma/client';
+import { TRPCError } from '@trpc/server';
+
+import { createRouter } from '../context';
+
+import type { Answer } from '~/types/questions';
+
+export const questionsAnswerRouter = createRouter()
+ .query('getAnswers', {
+ input: z.object({
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const { questionId } = input;
+
+ const answersData = await ctx.prisma.questionsAnswer.findMany({
+ include: {
+ _count: {
+ select: {
+ comments: true,
+ },
+ },
+ user: {
+ select: {
+ image: true,
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ where: {
+ questionId,
+ },
+ });
+ return answersData.map((data) => {
+ const votes: number = data.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const answer: Answer = {
+ content: data.content,
+ createdAt: data.createdAt,
+ id: data.id,
+ numComments: data._count.comments,
+ numVotes: votes,
+ user: data.user?.name ?? '',
+ userImage: data.user?.image ?? '',
+ };
+ return answer;
+ });
+ },
+ })
+ .query('getAnswerById', {
+ input: z.object({
+ answerId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const answerData = await ctx.prisma.questionsAnswer.findUnique({
+ include: {
+ _count: {
+ select: {
+ comments: true,
+ },
+ },
+ user: {
+ select: {
+ image: true,
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ where: {
+ id: input.answerId,
+ },
+ });
+ if (!answerData) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Answer not found',
+ });
+ }
+ const votes: number = answerData.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const answer: Answer = {
+ content: answerData.content,
+ createdAt: answerData.createdAt,
+ id: answerData.id,
+ numComments: answerData._count.comments,
+ numVotes: votes,
+ user: answerData.user?.name ?? '',
+ userImage: answerData.user?.image ?? '',
+ };
+ return answer;
+ },
+ });
diff --git a/apps/portal/src/server/router/questions-answer-router.ts b/apps/portal/src/server/router/questions/questions-answer-user-router.ts
similarity index 62%
rename from apps/portal/src/server/router/questions-answer-router.ts
rename to apps/portal/src/server/router/questions/questions-answer-user-router.ts
index e2318ba7..a73f5a21 100644
--- a/apps/portal/src/server/router/questions-answer-router.ts
+++ b/apps/portal/src/server/router/questions/questions-answer-user-router.ts
@@ -2,130 +2,9 @@ import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
-import { createProtectedRouter } from './context';
+import { createProtectedRouter } from '../context';
-import type { Answer } from '~/types/questions';
-
-export const questionsAnswerRouter = createProtectedRouter()
- .query('getAnswers', {
- input: z.object({
- questionId: z.string(),
- }),
- async resolve({ ctx, input }) {
- const { questionId } = input;
-
- const answersData = await ctx.prisma.questionsAnswer.findMany({
- include: {
- _count: {
- select: {
- comments: true,
- },
- },
- user: {
- select: {
- image: true,
- name: true,
- },
- },
- votes: true,
- },
- orderBy: {
- createdAt: 'desc',
- },
- where: {
- questionId,
- },
- });
- return answersData.map((data) => {
- const votes: number = data.votes.reduce(
- (previousValue: number, currentValue) => {
- let result: number = previousValue;
-
- switch (currentValue.vote) {
- case Vote.UPVOTE:
- result += 1;
- break;
- case Vote.DOWNVOTE:
- result -= 1;
- break;
- }
- return result;
- },
- 0,
- );
-
- const answer: Answer = {
- content: data.content,
- createdAt: data.createdAt,
- id: data.id,
- numComments: data._count.comments,
- numVotes: votes,
- user: data.user?.name ?? '',
- userImage: data.user?.image ?? '',
- };
- return answer;
- });
- },
- })
- .query('getAnswerById', {
- input: z.object({
- answerId: z.string(),
- }),
- async resolve({ ctx, input }) {
- const answerData = await ctx.prisma.questionsAnswer.findUnique({
- include: {
- _count: {
- select: {
- comments: true,
- },
- },
- user: {
- select: {
- image: true,
- name: true,
- },
- },
- votes: true,
- },
- where: {
- id: input.answerId,
- },
- });
- if (!answerData) {
- throw new TRPCError({
- code: 'NOT_FOUND',
- message: 'Answer not found',
- });
- }
- const votes: number = answerData.votes.reduce(
- (previousValue: number, currentValue) => {
- let result: number = previousValue;
-
- switch (currentValue.vote) {
- case Vote.UPVOTE:
- result += 1;
- break;
- case Vote.DOWNVOTE:
- result -= 1;
- break;
- }
- return result;
- },
- 0,
- );
-
- const answer: Answer = {
- content: answerData.content,
- createdAt: answerData.createdAt,
- id: answerData.id,
- numComments: answerData._count.comments,
- numVotes: votes,
- user: answerData.user?.name ?? '',
- userImage: answerData.user?.image ?? '',
- };
- return answer;
- },
- })
+export const questionsAnswerUserRouter = createProtectedRouter()
.mutation('create', {
input: z.object({
content: z.string(),
@@ -341,6 +220,5 @@ export const questionsAnswerRouter = createProtectedRouter()
}),
]);
return questionsAnswerVote;
-
},
});
diff --git a/apps/portal/src/server/router/questions/questions-list-router.ts b/apps/portal/src/server/router/questions/questions-list-router.ts
new file mode 100644
index 00000000..851c0a9c
--- /dev/null
+++ b/apps/portal/src/server/router/questions/questions-list-router.ts
@@ -0,0 +1,275 @@
+import { z } from 'zod';
+import { TRPCError } from '@trpc/server';
+
+import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
+
+import { createProtectedRouter } from '../context';
+
+export const questionsListRouter = createProtectedRouter()
+ .query('getListsByUser', {
+ async resolve({ ctx }) {
+ const userId = ctx.session?.user?.id;
+
+ // TODO: Optimize by not returning question entries
+ const questionsLists = await ctx.prisma.questionsList.findMany({
+ include: {
+ questionEntries: {
+ include: {
+ question: {
+ include: {
+ _count: {
+ select: {
+ answers: true,
+ comments: true,
+ },
+ },
+ encounters: {
+ select: {
+ company: true,
+ location: true,
+ role: true,
+ seenAt: true,
+ },
+ },
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'asc',
+ },
+ where: {
+ userId,
+ },
+ });
+
+ const lists = questionsLists.map((list) => ({
+ ...list,
+ questionEntries: list.questionEntries.map((entry) => ({
+ ...entry,
+ question: createQuestionWithAggregateData(entry.question),
+ })),
+ }));
+
+ return lists;
+ },
+ })
+ .query('getListById', {
+ input: z.object({
+ listId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { listId } = input;
+
+ const questionList = await ctx.prisma.questionsList.findFirst({
+ include: {
+ questionEntries: {
+ include: {
+ question: {
+ include: {
+ _count: {
+ select: {
+ answers: true,
+ comments: true,
+ },
+ },
+ encounters: {
+ select: {
+ company: true,
+ location: true,
+ role: true,
+ seenAt: true,
+ },
+ },
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'asc',
+ },
+ where: {
+ id: listId,
+ userId,
+ },
+ });
+
+ if (!questionList) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Question list not found',
+ });
+ }
+
+ return {
+ ...questionList,
+ questionEntries: questionList.questionEntries.map((questionEntry) => ({
+ ...questionEntry,
+ question: createQuestionWithAggregateData(questionEntry.question),
+ })),
+ };
+ },
+ })
+ .mutation('create', {
+ input: z.object({
+ name: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const { name } = input;
+
+ return await ctx.prisma.questionsList.create({
+ data: {
+ name,
+ userId,
+ },
+ });
+ },
+ })
+ .mutation('update', {
+ input: z.object({
+ id: z.string(),
+ name: z.string().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { name, id } = input;
+
+ const listToUpdate = await ctx.prisma.questionsList.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (listToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsList.update({
+ data: {
+ name,
+ },
+ where: {
+ id,
+ },
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const listToDelete = await ctx.prisma.questionsList.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (listToDelete?.userId !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsList.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ })
+ .mutation('createQuestionEntry', {
+ input: z.object({
+ listId: z.string(),
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const listToAugment = await ctx.prisma.questionsList.findUnique({
+ where: {
+ id: input.listId,
+ },
+ });
+
+ if (listToAugment?.userId !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ const { questionId, listId } = input;
+
+ return await ctx.prisma.questionsListQuestionEntry.create({
+ data: {
+ listId,
+ questionId,
+ },
+ });
+ },
+ })
+ .mutation('deleteQuestionEntry', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const entryToDelete =
+ await ctx.prisma.questionsListQuestionEntry.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (entryToDelete === null) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Entry not found.',
+ });
+ }
+
+ const listToAugment = await ctx.prisma.questionsList.findUnique({
+ where: {
+ id: entryToDelete.listId,
+ },
+ });
+
+ if (listToAugment?.userId !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsListQuestionEntry.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/questions/questions-question-comment-router.ts b/apps/portal/src/server/router/questions/questions-question-comment-router.ts
new file mode 100644
index 00000000..1bf63789
--- /dev/null
+++ b/apps/portal/src/server/router/questions/questions-question-comment-router.ts
@@ -0,0 +1,64 @@
+import { z } from 'zod';
+import { Vote } from '@prisma/client';
+
+import { createRouter } from '../context';
+
+import type { QuestionComment } from '~/types/questions';
+
+export const questionsQuestionCommentRouter = createRouter().query(
+ 'getQuestionComments',
+ {
+ input: z.object({
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const { questionId } = input;
+ const questionCommentsData =
+ await ctx.prisma.questionsQuestionComment.findMany({
+ include: {
+ user: {
+ select: {
+ image: true,
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ where: {
+ questionId,
+ },
+ });
+ return questionCommentsData.map((data) => {
+ const votes: number = data.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const questionComment: QuestionComment = {
+ content: data.content,
+ createdAt: data.createdAt,
+ id: data.id,
+ numVotes: votes,
+ user: data.user?.name ?? '',
+ userImage: data.user?.image ?? '',
+ };
+ return questionComment;
+ });
+ },
+ },
+);
diff --git a/apps/portal/src/server/router/questions-question-comment-router.ts b/apps/portal/src/server/router/questions/questions-question-comment-user-router.ts
similarity index 76%
rename from apps/portal/src/server/router/questions-question-comment-router.ts
rename to apps/portal/src/server/router/questions/questions-question-comment-user-router.ts
index 28cf3b9d..b216e44b 100644
--- a/apps/portal/src/server/router/questions-question-comment-router.ts
+++ b/apps/portal/src/server/router/questions/questions-question-comment-user-router.ts
@@ -2,65 +2,9 @@ import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
-import { createProtectedRouter } from './context';
+import { createProtectedRouter } from '../context';
-import type { QuestionComment } from '~/types/questions';
-
-export const questionsQuestionCommentRouter = createProtectedRouter()
- .query('getQuestionComments', {
- input: z.object({
- questionId: z.string(),
- }),
- async resolve({ ctx, input }) {
- const { questionId } = input;
- const questionCommentsData =
- await ctx.prisma.questionsQuestionComment.findMany({
- include: {
- user: {
- select: {
- image: true,
- name: true,
- },
- },
- votes: true,
- },
- orderBy: {
- createdAt: 'desc',
- },
- where: {
- questionId,
- },
- });
- return questionCommentsData.map((data) => {
- const votes: number = data.votes.reduce(
- (previousValue: number, currentValue) => {
- let result: number = previousValue;
-
- switch (currentValue.vote) {
- case Vote.UPVOTE:
- result += 1;
- break;
- case Vote.DOWNVOTE:
- result -= 1;
- break;
- }
- return result;
- },
- 0,
- );
-
- const questionComment: QuestionComment = {
- content: data.content,
- createdAt: data.createdAt,
- id: data.id,
- numVotes: votes,
- user: data.user?.name ?? '',
- userImage: data.user?.image ?? '',
- };
- return questionComment;
- });
- },
- })
+export const questionsQuestionCommentUserRouter = createProtectedRouter()
.mutation('create', {
input: z.object({
content: z.string(),
@@ -168,7 +112,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
const incrementValue: number = vote === Vote.UPVOTE ? 1 : -1;
- const [ questionCommentVote ] = await ctx.prisma.$transaction([
+ const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.create({
data: {
questionCommentId,
diff --git a/apps/portal/src/server/router/questions/questions-question-encounter-router.ts b/apps/portal/src/server/router/questions/questions-question-encounter-router.ts
new file mode 100644
index 00000000..31b11ab4
--- /dev/null
+++ b/apps/portal/src/server/router/questions/questions-question-encounter-router.ts
@@ -0,0 +1,61 @@
+import { z } from 'zod';
+
+import { createRouter } from '../context';
+
+import type { AggregatedQuestionEncounter } from '~/types/questions';
+
+export const questionsQuestionEncounterRouter = createRouter().query(
+ 'getAggregatedEncounters',
+ {
+ input: z.object({
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const questionEncountersData =
+ await ctx.prisma.questionsQuestionEncounter.findMany({
+ include: {
+ company: true,
+ },
+ where: {
+ ...input,
+ },
+ });
+
+ const companyCounts: Record = {};
+ const locationCounts: Record = {};
+ const roleCounts: Record = {};
+
+ let latestSeenAt = questionEncountersData[0].seenAt;
+
+ for (let i = 0; i < questionEncountersData.length; i++) {
+ const encounter = questionEncountersData[i];
+
+ latestSeenAt =
+ latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
+
+ if (!(encounter.company!.name in companyCounts)) {
+ companyCounts[encounter.company!.name] = 1;
+ }
+ companyCounts[encounter.company!.name] += 1;
+
+ if (!(encounter.location in locationCounts)) {
+ locationCounts[encounter.location] = 1;
+ }
+ locationCounts[encounter.location] += 1;
+
+ if (!(encounter.role in roleCounts)) {
+ roleCounts[encounter.role] = 1;
+ }
+ roleCounts[encounter.role] += 1;
+ }
+
+ const questionEncounter: AggregatedQuestionEncounter = {
+ companyCounts,
+ latestSeenAt,
+ locationCounts,
+ roleCounts,
+ };
+ return questionEncounter;
+ },
+ },
+);
diff --git a/apps/portal/src/server/router/questions/questions-question-encounter-user-router.ts b/apps/portal/src/server/router/questions/questions-question-encounter-user-router.ts
new file mode 100644
index 00000000..0089a15e
--- /dev/null
+++ b/apps/portal/src/server/router/questions/questions-question-encounter-user-router.ts
@@ -0,0 +1,207 @@
+import { z } from 'zod';
+import { TRPCError } from '@trpc/server';
+
+import { createAggregatedQuestionEncounter } from '~/utils/questions/server/aggregate-encounters';
+
+import { createProtectedRouter } from '../context';
+
+import { SortOrder } from '~/types/questions.d';
+
+export const questionsQuestionEncounterUserRouter = createProtectedRouter()
+ .query('getAggregatedEncounters', {
+ input: z.object({
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const questionEncountersData =
+ await ctx.prisma.questionsQuestionEncounter.findMany({
+ include: {
+ company: true,
+ },
+ where: {
+ ...input,
+ },
+ });
+
+ return createAggregatedQuestionEncounter(questionEncountersData);
+ },
+ })
+ .mutation('create', {
+ input: z.object({
+ companyId: z.string(),
+ location: z.string(),
+ questionId: z.string(),
+ role: z.string(),
+ seenAt: z.date(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ return await ctx.prisma.$transaction(async (tx) => {
+ const [questionToUpdate, questionEncounterCreated] = await Promise.all([
+ tx.questionsQuestion.findUnique({
+ where: {
+ id: input.questionId,
+ },
+ }),
+ tx.questionsQuestionEncounter.create({
+ data: {
+ ...input,
+ userId,
+ },
+ }),
+ ]);
+
+ if (questionToUpdate === null) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Question does not exist',
+ });
+ }
+
+ if (
+ questionToUpdate.lastSeenAt === null ||
+ questionToUpdate.lastSeenAt < input.seenAt
+ ) {
+ await tx.questionsQuestion.update({
+ data: {
+ lastSeenAt: input.seenAt,
+ },
+ where: {
+ id: input.questionId,
+ },
+ });
+ }
+ return questionEncounterCreated;
+ });
+ },
+ })
+ .mutation('update', {
+ input: z.object({
+ companyId: z.string().optional(),
+ id: z.string(),
+ location: z.string().optional(),
+ role: z.string().optional(),
+ seenAt: z.date().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const questionEncounterToUpdate =
+ await ctx.prisma.questionsQuestionEncounter.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (questionEncounterToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.$transaction(async (tx) => {
+ const [questionToUpdate, questionEncounterUpdated] = await Promise.all([
+ tx.questionsQuestion.findUnique({
+ where: {
+ id: questionEncounterToUpdate.questionId,
+ },
+ }),
+ tx.questionsQuestionEncounter.update({
+ data: {
+ ...input,
+ },
+ where: {
+ id: input.id,
+ },
+ }),
+ ]);
+
+ if (questionToUpdate!.lastSeenAt === questionEncounterToUpdate.seenAt) {
+ const latestEncounter =
+ await ctx.prisma.questionsQuestionEncounter.findFirst({
+ orderBy: {
+ seenAt: SortOrder.DESC,
+ },
+ where: {
+ questionId: questionToUpdate!.id,
+ },
+ });
+
+ await tx.questionsQuestion.update({
+ data: {
+ lastSeenAt: latestEncounter!.seenAt,
+ },
+ where: {
+ id: questionToUpdate!.id,
+ },
+ });
+ }
+
+ return questionEncounterUpdated;
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const questionEncounterToDelete =
+ await ctx.prisma.questionsQuestionEncounter.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (questionEncounterToDelete?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.$transaction(async (tx) => {
+ const [questionToUpdate, questionEncounterDeleted] = await Promise.all([
+ tx.questionsQuestion.findUnique({
+ where: {
+ id: questionEncounterToDelete.questionId,
+ },
+ }),
+ tx.questionsQuestionEncounter.delete({
+ where: {
+ id: input.id,
+ },
+ }),
+ ]);
+
+ if (questionToUpdate!.lastSeenAt === questionEncounterToDelete.seenAt) {
+ const latestEncounter =
+ await ctx.prisma.questionsQuestionEncounter.findFirst({
+ orderBy: {
+ seenAt: SortOrder.DESC,
+ },
+ where: {
+ questionId: questionToUpdate!.id,
+ },
+ });
+
+ const lastSeenVal = latestEncounter ? latestEncounter!.seenAt : null;
+
+ await tx.questionsQuestion.update({
+ data: {
+ lastSeenAt: lastSeenVal,
+ },
+ where: {
+ id: questionToUpdate!.id,
+ },
+ });
+ }
+
+ return questionEncounterDeleted;
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/questions/questions-question-router.ts b/apps/portal/src/server/router/questions/questions-question-router.ts
new file mode 100644
index 00000000..f3b48b9b
--- /dev/null
+++ b/apps/portal/src/server/router/questions/questions-question-router.ts
@@ -0,0 +1,196 @@
+import { z } from 'zod';
+import { QuestionsQuestionType } from '@prisma/client';
+import { TRPCError } from '@trpc/server';
+
+import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
+
+import { createRouter } from '../context';
+
+import { SortOrder, SortType } from '~/types/questions.d';
+
+export const questionsQuestionRouter = createRouter()
+ .query('getQuestionsByFilter', {
+ input: z.object({
+ companyNames: z.string().array(),
+ cursor: z
+ .object({
+ idCursor: z.string().optional(),
+ lastSeenCursor: z.date().nullish().optional(),
+ upvoteCursor: z.number().optional(),
+ })
+ .nullish(),
+ endDate: z.date().default(new Date()),
+ limit: z.number().min(1).default(50),
+ locations: z.string().array(),
+ questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
+ roles: z.string().array(),
+ sortOrder: z.nativeEnum(SortOrder),
+ sortType: z.nativeEnum(SortType),
+ startDate: z.date().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const { cursor } = input;
+
+ const sortCondition =
+ input.sortType === SortType.TOP
+ ? [
+ {
+ upvotes: input.sortOrder,
+ },
+ {
+ id: input.sortOrder,
+ },
+ ]
+ : [
+ {
+ lastSeenAt: input.sortOrder,
+ },
+ {
+ id: input.sortOrder,
+ },
+ ];
+
+ const questionsData = await ctx.prisma.questionsQuestion.findMany({
+ cursor:
+ cursor !== undefined
+ ? {
+ id: cursor ? cursor!.idCursor : undefined,
+ }
+ : undefined,
+ include: {
+ _count: {
+ select: {
+ answers: true,
+ comments: true,
+ },
+ },
+ encounters: {
+ select: {
+ company: true,
+ location: true,
+ role: true,
+ seenAt: true,
+ },
+ },
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ orderBy: sortCondition,
+ take: input.limit + 1,
+ where: {
+ ...(input.questionTypes.length > 0
+ ? {
+ questionType: {
+ in: input.questionTypes,
+ },
+ }
+ : {}),
+ encounters: {
+ some: {
+ seenAt: {
+ gte: input.startDate,
+ lte: input.endDate,
+ },
+ ...(input.companyNames.length > 0
+ ? {
+ company: {
+ name: {
+ in: input.companyNames,
+ },
+ },
+ }
+ : {}),
+ ...(input.locations.length > 0
+ ? {
+ location: {
+ in: input.locations,
+ },
+ }
+ : {}),
+ ...(input.roles.length > 0
+ ? {
+ role: {
+ in: input.roles,
+ },
+ }
+ : {}),
+ },
+ },
+ },
+ });
+
+ const processedQuestionsData = questionsData.map(
+ createQuestionWithAggregateData,
+ );
+
+ let nextCursor: typeof cursor | undefined = undefined;
+
+ if (questionsData.length > input.limit) {
+ const nextItem = questionsData.pop()!;
+ processedQuestionsData.pop();
+
+ const nextIdCursor: string | undefined = nextItem.id;
+ const nextLastSeenCursor =
+ input.sortType === SortType.NEW ? nextItem.lastSeenAt : undefined;
+ const nextUpvoteCursor =
+ input.sortType === SortType.TOP ? nextItem.upvotes : undefined;
+
+ nextCursor = {
+ idCursor: nextIdCursor,
+ lastSeenCursor: nextLastSeenCursor,
+ upvoteCursor: nextUpvoteCursor,
+ };
+ }
+
+ return {
+ data: processedQuestionsData,
+ nextCursor,
+ };
+ },
+ })
+ .query('getQuestionById', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const questionData = await ctx.prisma.questionsQuestion.findUnique({
+ include: {
+ _count: {
+ select: {
+ answers: true,
+ comments: true,
+ },
+ },
+ encounters: {
+ select: {
+ company: true,
+ location: true,
+ role: true,
+ seenAt: true,
+ },
+ },
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ where: {
+ id: input.id,
+ },
+ });
+ if (!questionData) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Question not found',
+ });
+ }
+
+ return createQuestionWithAggregateData(questionData);
+ },
+ });
diff --git a/apps/portal/src/server/router/questions/questions-question-user-router.ts b/apps/portal/src/server/router/questions/questions-question-user-router.ts
new file mode 100644
index 00000000..5a2a2c72
--- /dev/null
+++ b/apps/portal/src/server/router/questions/questions-question-user-router.ts
@@ -0,0 +1,248 @@
+import { z } from 'zod';
+import { QuestionsQuestionType, Vote } from '@prisma/client';
+import { TRPCError } from '@trpc/server';
+
+import { createProtectedRouter } from '../context';
+
+export const questionsQuestionUserRouter = createProtectedRouter()
+ .mutation('create', {
+ input: z.object({
+ companyId: z.string(),
+ content: z.string(),
+ location: z.string(),
+ questionType: z.nativeEnum(QuestionsQuestionType),
+ role: z.string(),
+ seenAt: z.date(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ return await ctx.prisma.questionsQuestion.create({
+ data: {
+ content: input.content,
+ encounters: {
+ create: {
+ company: {
+ connect: {
+ id: input.companyId,
+ },
+ },
+ location: input.location,
+ role: input.role,
+ seenAt: input.seenAt,
+ user: {
+ connect: {
+ id: userId,
+ },
+ },
+ },
+ },
+ lastSeenAt: input.seenAt,
+ questionType: input.questionType,
+ userId,
+ },
+ });
+ },
+ })
+ .mutation('update', {
+ input: z.object({
+ content: z.string().optional(),
+ id: z.string(),
+ questionType: z.nativeEnum(QuestionsQuestionType).optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const questionToUpdate = await ctx.prisma.questionsQuestion.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (questionToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ // Optional: pass the original error to retain stack trace
+ });
+ }
+
+ const { content, questionType } = input;
+
+ return await ctx.prisma.questionsQuestion.update({
+ data: {
+ content,
+ questionType,
+ },
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const questionToDelete = await ctx.prisma.questionsQuestion.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (questionToDelete?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ // Optional: pass the original error to retain stack trace
+ });
+ }
+
+ return await ctx.prisma.questionsQuestion.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ })
+ .query('getVote', {
+ input: z.object({
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { questionId } = input;
+
+ return await ctx.prisma.questionsQuestionVote.findUnique({
+ where: {
+ questionId_userId: { questionId, userId },
+ },
+ });
+ },
+ })
+ .mutation('createVote', {
+ input: z.object({
+ questionId: z.string(),
+ vote: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { questionId, vote } = input;
+
+ const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
+
+ const [questionVote] = await ctx.prisma.$transaction([
+ ctx.prisma.questionsQuestionVote.create({
+ data: {
+ questionId,
+ userId,
+ vote,
+ },
+ }),
+ ctx.prisma.questionsQuestion.update({
+ data: {
+ upvotes: {
+ increment: incrementValue,
+ },
+ },
+ where: {
+ id: questionId,
+ },
+ }),
+ ]);
+ return questionVote;
+ },
+ })
+ .mutation('updateVote', {
+ input: z.object({
+ id: z.string(),
+ vote: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { id, vote } = input;
+
+ const voteToUpdate = await ctx.prisma.questionsQuestionVote.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (voteToUpdate?.userId !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
+
+ const [questionVote] = await ctx.prisma.$transaction([
+ ctx.prisma.questionsQuestionVote.update({
+ data: {
+ vote,
+ },
+ where: {
+ id,
+ },
+ }),
+ ctx.prisma.questionsQuestion.update({
+ data: {
+ upvotes: {
+ increment: incrementValue,
+ },
+ },
+ where: {
+ id: voteToUpdate.questionId,
+ },
+ }),
+ ]);
+
+ return questionVote;
+ },
+ })
+ .mutation('deleteVote', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (voteToDelete?.userId !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
+
+ const [questionVote] = await ctx.prisma.$transaction([
+ ctx.prisma.questionsQuestionVote.delete({
+ where: {
+ id: input.id,
+ },
+ }),
+ ctx.prisma.questionsQuestion.update({
+ data: {
+ upvotes: {
+ increment: incrementValue,
+ },
+ },
+ where: {
+ id: voteToDelete.questionId,
+ },
+ }),
+ ]);
+ return questionVote;
+ },
+ });
diff --git a/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts
index d061a4c2..e33a399b 100644
--- a/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts
@@ -45,9 +45,19 @@ export const resumesCommentsUserRouter = createProtectedRouter()
};
});
- return await ctx.prisma.resumesComment.createMany({
+ const prevCommentCount = await ctx.prisma.resumesComment.count({
+ where: {
+ resumeId,
+ },
+ });
+ const result = await ctx.prisma.resumesComment.createMany({
data: comments,
});
+
+ return {
+ newCount: Number(prevCommentCount) + result.count,
+ prevCount: prevCommentCount,
+ };
},
})
.mutation('update', {
diff --git a/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts
index 5d508c35..7c820971 100644
--- a/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts
@@ -1,5 +1,4 @@
import { z } from 'zod';
-import type { ResumesCommentVote } from '@prisma/client';
import { Vote } from '@prisma/client';
import { createRouter } from '../context';
@@ -20,13 +19,13 @@ export const resumesCommentsVotesRouter = createRouter().query('list', {
},
});
- let userVote: ResumesCommentVote | null = null;
- let numVotes = 0;
-
- votes.forEach((vote) => {
- numVotes += vote.value === Vote.UPVOTE ? 1 : -1;
- userVote = vote.userId === userId ? vote : null;
- });
+ const userVotes = votes.filter((vote) => vote.userId === userId);
+ const userVote = userVotes.length > 0 ? userVotes[0] : null;
+ const numVotes = votes
+ .map((vote) => (vote.value === Vote.UPVOTE ? 1 : -1))
+ .reduce((result, current) => {
+ return result + current;
+ }, 0);
const resumeCommentVote: ResumeCommentVote = {
numVotes,
diff --git a/apps/portal/src/server/router/resumes/resumes-resume-router.ts b/apps/portal/src/server/router/resumes/resumes-resume-router.ts
index dee4627d..1b062c10 100644
--- a/apps/portal/src/server/router/resumes/resumes-resume-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-resume-router.ts
@@ -1,6 +1,8 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
+import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
+
import { createRouter } from '../context';
import type { Resume } from '~/types/resume';
@@ -96,6 +98,7 @@ export const resumesRouter = createRouter()
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
+ isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
numComments: r._count.comments,
@@ -250,4 +253,72 @@ export const resumesRouter = createRouter()
return topUpvotedCommentCount;
},
+ })
+ .query('getTotalFilterCounts', {
+ async resolve({ ctx }) {
+ const roleCounts = await ctx.prisma.resumesResume.groupBy({
+ _count: {
+ _all: true,
+ },
+ by: ['role'],
+ });
+ const mappedRoleCounts = Object.fromEntries(
+ roleCounts.map((rc) => [rc.role, rc._count._all]),
+ );
+ const zeroRoleCounts = Object.fromEntries(
+ ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
+ r.value,
+ 0,
+ ]),
+ );
+ const processedRoleCounts = {
+ ...mappedRoleCounts,
+ ...zeroRoleCounts,
+ };
+
+ const experienceCounts = await ctx.prisma.resumesResume.groupBy({
+ _count: {
+ _all: true,
+ },
+ by: ['experience'],
+ });
+ const mappedExperienceCounts = Object.fromEntries(
+ experienceCounts.map((ec) => [ec.experience, ec._count._all]),
+ );
+ const zeroExperienceCounts = Object.fromEntries(
+ EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
+ (e) => [e.value, 0],
+ ),
+ );
+ const processedExperienceCounts = {
+ ...mappedExperienceCounts,
+ ...zeroExperienceCounts,
+ };
+
+ const locationCounts = await ctx.prisma.resumesResume.groupBy({
+ _count: {
+ _all: true,
+ },
+ by: ['location'],
+ });
+ const mappedLocationCounts = Object.fromEntries(
+ locationCounts.map((lc) => [lc.location, lc._count._all]),
+ );
+ const zeroLocationCounts = Object.fromEntries(
+ LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
+ l.value,
+ 0,
+ ]),
+ );
+ const processedLocationCounts = {
+ ...mappedLocationCounts,
+ ...zeroLocationCounts,
+ };
+
+ return {
+ Experience: processedExperienceCounts,
+ Location: processedLocationCounts,
+ Role: processedRoleCounts,
+ };
+ },
});
diff --git a/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts b/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts
index 71bd905f..7858afb3 100644
--- a/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts
@@ -44,6 +44,23 @@ export const resumesResumeUserRouter = createProtectedRouter()
});
},
})
+ .mutation('resolve', {
+ input: z.object({
+ id: z.string(),
+ val: z.boolean(),
+ }),
+ async resolve({ ctx, input }) {
+ const resume = await ctx.prisma.resumesResume.update({
+ data: {
+ isResolved: input.val,
+ },
+ where: {
+ id: input.id,
+ },
+ });
+ return resume.isResolved;
+ },
+ })
.query('findUserStarred', {
input: z.object({
experienceFilters: z.string().array(),
@@ -147,6 +164,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
createdAt: rs.resume.createdAt,
experience: rs.resume.experience,
id: rs.resume.id,
+ isResolved: rs.resume.isResolved,
isStarredByUser: true,
location: rs.resume.location,
numComments: rs.resume._count.comments,
@@ -250,6 +268,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
+ isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
numComments: r._count.comments,
diff --git a/apps/portal/src/types/index.d.ts b/apps/portal/src/types/index.d.ts
new file mode 100644
index 00000000..08f79f5a
--- /dev/null
+++ b/apps/portal/src/types/index.d.ts
@@ -0,0 +1,9 @@
+export {};
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+ interface Window {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ gtag: any;
+ }
+}
diff --git a/apps/portal/src/types/offers.d.ts b/apps/portal/src/types/offers.d.ts
index f2b26332..b26995d7 100644
--- a/apps/portal/src/types/offers.d.ts
+++ b/apps/portal/src/types/offers.d.ts
@@ -6,6 +6,7 @@ export type Profile = {
editToken: string?;
id: string;
isEditable: boolean;
+ isSaved: boolean;
offers: Array;
profileName: string;
};
@@ -183,3 +184,22 @@ export type AddToProfileResponse = {
profileName: string;
userId: string;
};
+
+export type UserProfile = {
+ createdAt: Date;
+ id: string;
+ offers: Array;
+ profileName: string;
+ token: string;
+}
+
+export type UserProfileOffer = {
+ company: OffersCompany;
+ id: string;
+ income: Valuation;
+ jobType: JobType;
+ level: string;
+ location: string;
+ monthYearReceived: Date;
+ title: string;
+}
\ No newline at end of file
diff --git a/apps/portal/src/types/resume.d.ts b/apps/portal/src/types/resume.d.ts
index 39e782bb..c9a3a567 100644
--- a/apps/portal/src/types/resume.d.ts
+++ b/apps/portal/src/types/resume.d.ts
@@ -3,6 +3,7 @@ export type Resume = {
createdAt: Date;
experience: string;
id: string;
+ isResolved: boolean;
isStarredByUser: boolean;
location: string;
numComments: number;
diff --git a/apps/portal/src/utils/offers/randomGenerator.ts b/apps/portal/src/utils/offers/randomGenerator.ts
index c0a05ac9..74b10a86 100644
--- a/apps/portal/src/utils/offers/randomGenerator.ts
+++ b/apps/portal/src/utils/offers/randomGenerator.ts
@@ -1,9 +1,14 @@
-import type { Config} from 'unique-names-generator';
+import type { Config } from 'unique-names-generator';
import { countries, names } from 'unique-names-generator';
-import { adjectives, animals,colors, uniqueNamesGenerator } from 'unique-names-generator';
+import {
+ adjectives,
+ animals,
+ colors,
+ uniqueNamesGenerator,
+} from 'unique-names-generator';
import { PrismaClient } from '@prisma/client';
-const prisma = new PrismaClient()
+const prisma = new PrismaClient();
const customConfig: Config = {
dictionaries: [adjectives, colors, animals],
@@ -12,33 +17,34 @@ const customConfig: Config = {
};
export async function generateRandomName(): Promise {
- let uniqueName: string = uniqueNamesGenerator(customConfig);
-
- let sameNameProfiles = await prisma.offersProfile.findMany({
- where: {
- profileName: uniqueName
- }
- })
-
- while (sameNameProfiles.length !== 0) {
- uniqueName = uniqueNamesGenerator(customConfig);
- sameNameProfiles = await prisma.offersProfile.findMany({
- where: {
- profileName: uniqueName
- }
- })
- }
-
- return uniqueName
+ let uniqueName: string = uniqueNamesGenerator(customConfig);
+
+ let sameNameProfiles = await prisma.offersProfile.findMany({
+ where: {
+ profileName: uniqueName,
+ },
+ });
+
+ while (sameNameProfiles.length !== 0) {
+ uniqueName = uniqueNamesGenerator(customConfig);
+ sameNameProfiles = await prisma.offersProfile.findMany({
+ where: {
+ profileName: uniqueName,
+ },
+ });
+ }
+
+ return uniqueName;
}
const tokenConfig: Config = {
- dictionaries: [adjectives, colors, animals, countries, names]
- .sort((_a, _b) => 0.5 - Math.random()),
+ dictionaries: [adjectives, colors, animals, countries, names].sort(
+ (_a, _b) => 0.5 - Math.random(),
+ ),
length: 5,
separator: '-',
};
export function generateRandomStringForToken(): string {
- return uniqueNamesGenerator(tokenConfig)
-}
\ No newline at end of file
+ return uniqueNamesGenerator(tokenConfig);
+}
diff --git a/apps/portal/src/utils/questions/server/aggregate-encounters.ts b/apps/portal/src/utils/questions/server/aggregate-encounters.ts
new file mode 100644
index 00000000..a0fdacef
--- /dev/null
+++ b/apps/portal/src/utils/questions/server/aggregate-encounters.ts
@@ -0,0 +1,102 @@
+import type {
+ Company,
+ QuestionsQuestion,
+ QuestionsQuestionVote,
+} from '@prisma/client';
+import { Vote } from '@prisma/client';
+
+import type { AggregatedQuestionEncounter, Question } from '~/types/questions';
+
+type AggregatableEncounters = Array<{
+ company: Company | null;
+ location: string;
+ role: string;
+ seenAt: Date;
+}>;
+
+type QuestionWithAggregatableData = QuestionsQuestion & {
+ _count: {
+ answers: number;
+ comments: number;
+ };
+ encounters: AggregatableEncounters;
+ user: {
+ name: string | null;
+ } | null;
+ votes: Array;
+};
+
+export function createQuestionWithAggregateData(
+ data: QuestionWithAggregatableData,
+): Question {
+ const votes: number = data.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const question: Question = {
+ aggregatedQuestionEncounters: createAggregatedQuestionEncounter(
+ data.encounters,
+ ),
+ content: data.content,
+ id: data.id,
+ numAnswers: data._count.answers,
+ numComments: data._count.comments,
+ numVotes: votes,
+ receivedCount: data.encounters.length,
+ seenAt: data.encounters[0].seenAt,
+ type: data.questionType,
+ updatedAt: data.updatedAt,
+ user: data.user?.name ?? '',
+ };
+ return question;
+}
+
+export function createAggregatedQuestionEncounter(
+ encounters: AggregatableEncounters,
+): AggregatedQuestionEncounter {
+ const companyCounts: Record = {};
+ const locationCounts: Record = {};
+ const roleCounts: Record = {};
+
+ let latestSeenAt = encounters[0].seenAt;
+
+ for (const encounter of encounters) {
+ latestSeenAt =
+ latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
+
+ if (!(encounter.company!.name in companyCounts)) {
+ companyCounts[encounter.company!.name] = 0;
+ }
+ companyCounts[encounter.company!.name] += 1;
+
+ if (!(encounter.location in locationCounts)) {
+ locationCounts[encounter.location] = 0;
+ }
+ locationCounts[encounter.location] += 1;
+
+ if (!(encounter.role in roleCounts)) {
+ roleCounts[encounter.role] = 0;
+ }
+ roleCounts[encounter.role] += 1;
+ }
+
+ return {
+ companyCounts,
+ latestSeenAt,
+ locationCounts,
+ roleCounts,
+ };
+}
diff --git a/apps/portal/src/utils/questions/useSearchParam.ts b/apps/portal/src/utils/questions/useSearchParam.ts
index 3895c5b4..b8ae89d8 100644
--- a/apps/portal/src/utils/questions/useSearchParam.ts
+++ b/apps/portal/src/utils/questions/useSearchParam.ts
@@ -25,7 +25,7 @@ export const useSearchParam = (
const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter();
- const [filters, setFilters] = useState>(defaultValues || []);
+ const [params, setParams] = useState>(defaultValues || []);
useEffect(() => {
if (router.isReady && !isInitialized) {
@@ -33,7 +33,7 @@ export const useSearchParam = (
const query = router.query[name];
if (query) {
const queryValues = Array.isArray(query) ? query : [query];
- setFilters(
+ setParams(
queryValues
.map(stringToParam)
.filter((value) => value !== null) as Array,
@@ -42,28 +42,32 @@ export const useSearchParam = (
// Try to load from local storage
const localStorageValue = localStorage.getItem(name);
if (localStorageValue !== null) {
- const loadedFilters = JSON.parse(localStorageValue);
- setFilters(loadedFilters);
+ const loadedFilters = JSON.parse(localStorageValue) as Array;
+ setParams(
+ loadedFilters
+ .map(stringToParam)
+ .filter((value) => value !== null) as Array,
+ );
}
}
setIsInitialized(true);
}
}, [isInitialized, name, stringToParam, router]);
- const setFiltersCallback = useCallback(
- (newFilters: Array) => {
- setFilters(newFilters);
+ const setParamsCallback = useCallback(
+ (newParams: Array) => {
+ setParams(newParams);
localStorage.setItem(
name,
JSON.stringify(
- newFilters.map(valueToQueryParam).filter((param) => param !== null),
+ newParams.map(valueToQueryParam).filter((param) => param !== null),
),
);
},
[name, valueToQueryParam],
);
- return [filters, setFiltersCallback, isInitialized] as const;
+ return [params, setParamsCallback, isInitialized] as const;
};
export const useSearchParamSingle = (
@@ -73,14 +77,14 @@ export const useSearchParamSingle = (
},
) => {
const { defaultValue, ...restOpts } = opts ?? {};
- const [filters, setFilters, isInitialized] = useSearchParam(name, {
+ const [params, setParams, isInitialized] = useSearchParam(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
...restOpts,
} as SearchParamOptions);
return [
- filters[0],
- (value: Value) => setFilters([value]),
+ params[0],
+ (value: Value) => setParams([value]),
isInitialized,
] as const;
};
diff --git a/apps/portal/src/utils/questions/useVote.ts b/apps/portal/src/utils/questions/useVote.ts
index 1bb42e8f..dcac2164 100644
--- a/apps/portal/src/utils/questions/useVote.ts
+++ b/apps/portal/src/utils/questions/useVote.ts
@@ -71,51 +71,51 @@ type QueryKey = Parameters[0][0];
export const useQuestionVote = (id: string) => {
return useVote(id, {
- create: 'questions.questions.createVote',
- deleteKey: 'questions.questions.deleteVote',
+ create: 'questions.questions.user.createVote',
+ deleteKey: 'questions.questions.user.deleteVote',
idKey: 'questionId',
invalidateKeys: [
'questions.questions.getQuestionsByFilter',
'questions.questions.getQuestionById',
],
- query: 'questions.questions.getVote',
- update: 'questions.questions.updateVote',
+ query: 'questions.questions.user.getVote',
+ update: 'questions.questions.user.updateVote',
});
};
export const useAnswerVote = (id: string) => {
return useVote(id, {
- create: 'questions.answers.createVote',
- deleteKey: 'questions.answers.deleteVote',
+ create: 'questions.answers.user.createVote',
+ deleteKey: 'questions.answers.user.deleteVote',
idKey: 'answerId',
invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById',
],
- query: 'questions.answers.getVote',
- update: 'questions.answers.updateVote',
+ query: 'questions.answers.user.getVote',
+ update: 'questions.answers.user.updateVote',
});
};
export const useQuestionCommentVote = (id: string) => {
return useVote(id, {
- create: 'questions.questions.comments.createVote',
- deleteKey: 'questions.questions.comments.deleteVote',
+ create: 'questions.questions.comments.user.createVote',
+ deleteKey: 'questions.questions.comments.user.deleteVote',
idKey: 'questionCommentId',
invalidateKeys: ['questions.questions.comments.getQuestionComments'],
- query: 'questions.questions.comments.getVote',
- update: 'questions.questions.comments.updateVote',
+ query: 'questions.questions.comments.user.getVote',
+ update: 'questions.questions.comments.user.updateVote',
});
};
export const useAnswerCommentVote = (id: string) => {
return useVote(id, {
- create: 'questions.answers.comments.createVote',
- deleteKey: 'questions.answers.comments.deleteVote',
+ create: 'questions.answers.comments.user.createVote',
+ deleteKey: 'questions.answers.comments.user.deleteVote',
idKey: 'answerCommentId',
invalidateKeys: ['questions.answers.comments.getAnswerComments'],
- query: 'questions.answers.comments.getVote',
- update: 'questions.answers.comments.updateVote',
+ query: 'questions.answers.comments.user.getVote',
+ update: 'questions.answers.comments.user.updateVote',
});
};
diff --git a/apps/portal/src/components/resumes/browse/resumeFilters.ts b/apps/portal/src/utils/resumes/resumeFilters.ts
similarity index 77%
rename from apps/portal/src/components/resumes/browse/resumeFilters.ts
rename to apps/portal/src/utils/resumes/resumeFilters.ts
index e0c4b0b5..cf030d4e 100644
--- a/apps/portal/src/components/resumes/browse/resumeFilters.ts
+++ b/apps/portal/src/utils/resumes/resumeFilters.ts
@@ -1,10 +1,11 @@
export type FilterId = 'experience' | 'location' | 'role';
+export type FilterLabel = 'Experience' | 'Location' | 'Role';
export type CustomFilter = {
numComments: number;
};
-type RoleFilter =
+export type RoleFilter =
| 'Android Engineer'
| 'Backend Engineer'
| 'DevOps Engineer'
@@ -12,16 +13,13 @@ type RoleFilter =
| 'Full-Stack Engineer'
| 'iOS Engineer';
-type ExperienceFilter =
+export type ExperienceFilter =
| 'Entry Level (0 - 2 years)'
- | 'Freshman'
- | 'Junior'
+ | 'Internship'
| 'Mid Level (3 - 5 years)'
- | 'Senior Level (5+ years)'
- | 'Senior'
- | 'Sophomore';
+ | 'Senior Level (5+ years)';
-type LocationFilter = 'India' | 'Singapore' | 'United States';
+export type LocationFilter = 'India' | 'Singapore' | 'United States';
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
@@ -32,14 +30,14 @@ export type FilterOption = {
export type Filter = {
id: FilterId;
- label: string;
+ label: FilterLabel;
options: Array>;
};
export type FilterState = Partial &
Record>;
-export type SortOrder = 'latest' | 'popular' | 'topComments';
+export type SortOrder = 'latest' | 'mostComments' | 'popular';
export type Shortcut = {
customFilters?: CustomFilter;
@@ -54,11 +52,11 @@ export const BROWSE_TABS_VALUES = {
STARRED: 'starred',
};
-export const SORT_OPTIONS: Record = {
- latest: 'Latest',
- popular: 'Popular',
- topComments: 'Most Comments',
-};
+export const SORT_OPTIONS: Array> = [
+ { label: 'Latest', value: 'latest' },
+ { label: 'Popular', value: 'popular' },
+ { label: 'Most Comments', value: 'mostComments' },
+];
export const ROLES: Array> = [
{
@@ -73,10 +71,7 @@ export const ROLES: Array> = [
];
export const EXPERIENCES: Array> = [
- { label: 'Freshman', value: 'Freshman' },
- { label: 'Sophomore', value: 'Sophomore' },
- { label: 'Junior', value: 'Junior' },
- { label: 'Senior', value: 'Senior' },
+ { label: 'Internship', value: 'Internship' },
{
label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)',
@@ -127,7 +122,7 @@ export const SHORTCUTS: Array = [
},
{
filters: INITIAL_FILTER_STATE,
- name: 'GOATs',
+ name: 'Top 10',
sortOrder: 'popular',
},
{
@@ -149,3 +144,10 @@ export const isInitialFilterState = (filters: FilterState) =>
filters[filter as FilterId].includes(value),
);
});
+
+export const getFilterLabel = (
+ filters: Array<
+ FilterOption
+ >,
+ filterValue: ExperienceFilter | LocationFilter | RoleFilter | SortOrder,
+) => filters.find(({ value }) => value === filterValue)?.label ?? filterValue;
diff --git a/apps/portal/src/utils/resumes/useSearchParams.ts b/apps/portal/src/utils/resumes/useSearchParams.ts
new file mode 100644
index 00000000..0bea502c
--- /dev/null
+++ b/apps/portal/src/utils/resumes/useSearchParams.ts
@@ -0,0 +1,26 @@
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+
+export const useSearchParams = (name: string, defaultValue: T) => {
+ const [isInitialized, setIsInitialized] = useState(false);
+ const router = useRouter();
+
+ const [filters, setFilters] = useState(defaultValue);
+
+ useEffect(() => {
+ if (router.isReady && !isInitialized) {
+ // Initialize from url query params
+ const query = router.query[name];
+ if (query) {
+ const parsedQuery =
+ typeof query === 'string' ? JSON.parse(query) : query;
+ setFilters(parsedQuery);
+ }
+ setIsInitialized(true);
+ }
+ }, [isInitialized, name, router]);
+
+ return [filters, setFilters, isInitialized] as const;
+};
+
+export default useSearchParams;
diff --git a/apps/storybook/stories/banner.stories.tsx b/apps/storybook/stories/banner.stories.tsx
new file mode 100644
index 00000000..4054f470
--- /dev/null
+++ b/apps/storybook/stories/banner.stories.tsx
@@ -0,0 +1,53 @@
+import React, { useState } from 'react';
+import type { ComponentMeta } from '@storybook/react';
+import type { BannerSize } from '@tih/ui';
+import { Banner } from '@tih/ui';
+
+const bannerSizes: ReadonlyArray = ['xs', 'sm', 'md'];
+
+export default {
+ argTypes: {
+ children: {
+ control: 'text',
+ },
+ size: {
+ control: { type: 'select' },
+ options: bannerSizes,
+ },
+ },
+ component: Banner,
+ title: 'Banner',
+} as ComponentMeta;
+
+export const Basic = {
+ args: {
+ children: 'This notice is going to change your life',
+ size: 'md',
+ },
+};
+
+export function Sizes() {
+ const [isShown, setIsShown] = useState(true);
+ const [isShown2, setIsShown2] = useState(true);
+ const [isShown3, setIsShown3] = useState(true);
+ return (
+
+ {isShown && (
+ setIsShown(false)}>
+ This notice is going to change your life unless you close it.
+
+ )}
+ {isShown2 && (
+ setIsShown2(false)}>
+ This smaller notice is going to change your life unless you close it.
+
+ )}
+ {isShown3 && (
+ setIsShown3(false)}>
+ This even smaller notice is going to change your life unless you close
+ it.
+
+ )}
+
+ );
+}
diff --git a/apps/storybook/stories/typeahead.stories.tsx b/apps/storybook/stories/typeahead.stories.tsx
index defffbf4..d8ca877a 100644
--- a/apps/storybook/stories/typeahead.stories.tsx
+++ b/apps/storybook/stories/typeahead.stories.tsx
@@ -1,8 +1,13 @@
import React, { useState } from 'react';
import type { ComponentMeta } from '@storybook/react';
-import type { TypeaheadOption } from '@tih/ui';
+import type { TypeaheadOption, TypeaheadTextSize } from '@tih/ui';
import { Typeahead } from '@tih/ui';
+const typeaheadTextSizes: ReadonlyArray = [
+ 'default',
+ 'inherit',
+];
+
export default {
argTypes: {
disabled: {
@@ -23,6 +28,10 @@ export default {
required: {
control: 'boolean',
},
+ textSize: {
+ control: { type: 'select' },
+ options: typeaheadTextSizes,
+ },
},
component: Typeahead,
parameters: {
diff --git a/packages/ui/src/Banner/Banner.tsx b/packages/ui/src/Banner/Banner.tsx
new file mode 100644
index 00000000..41403cbb
--- /dev/null
+++ b/packages/ui/src/Banner/Banner.tsx
@@ -0,0 +1,50 @@
+import clsx from 'clsx';
+import React from 'react';
+import { XMarkIcon } from '@heroicons/react/24/outline';
+
+export type BannerSize = 'md' | 'sm' | 'xs';
+
+type Props = Readonly<{
+ children: React.ReactNode;
+ onHide?: () => void;
+ size?: BannerSize;
+}>;
+
+export default function Banner({ children, size = 'md', onHide }: Props) {
+ return (
+
+
+
+ {onHide != null && (
+
+
+ Dismiss
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/ui/src/Pagination/Pagination.tsx b/packages/ui/src/Pagination/Pagination.tsx
index 59c72a36..6ce812b4 100644
--- a/packages/ui/src/Pagination/Pagination.tsx
+++ b/packages/ui/src/Pagination/Pagination.tsx
@@ -85,7 +85,7 @@ export default function Pagination({
}
if (lastAddedPage < current - pagePadding - 1) {
- elements.push();
+ elements.push();
}
for (let i = current - pagePadding; i <= current + pagePadding; i++) {
@@ -93,7 +93,7 @@ export default function Pagination({
}
if (lastAddedPage < end - pagePadding - 1) {
- elements.push();
+ elements.push();
}
for (let i = end - pagePadding; i <= end; i++) {
diff --git a/packages/ui/src/Typeahead/Typeahead.tsx b/packages/ui/src/Typeahead/Typeahead.tsx
index 76cb71af..b15aa859 100644
--- a/packages/ui/src/Typeahead/Typeahead.tsx
+++ b/packages/ui/src/Typeahead/Typeahead.tsx
@@ -10,6 +10,7 @@ export type TypeaheadOption = Readonly<{
label: string;
value: string;
}>;
+export type TypeaheadTextSize = 'default' | 'inherit';
type Attributes = Pick<
InputHTMLAttributes,
@@ -33,10 +34,16 @@ type Props = Readonly<{
) => void;
onSelect: (option: TypeaheadOption) => void;
options: ReadonlyArray;
+ textSize?: TypeaheadTextSize;
value?: TypeaheadOption;
}> &
Readonly;
+const textSizes: Record = {
+ default: 'text-sm',
+ inherit: '',
+};
+
export default function Typeahead({
disabled = false,
isLabelHidden,
@@ -46,108 +53,123 @@ export default function Typeahead({
options,
onQueryChange,
required,
+ textSize = 'default',
value,
onSelect,
...props
}: Props) {
const [query, setQuery] = useState('');
return (
- {
- if (newValue == null) {
- return;
- }
-
+
+
-
- {label}
- {required && (
-
- {' '}
- *
-
- )}
-
-
-
-
{
+ if (newValue == null) {
+ return;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ onSelect(newValue as TypeaheadOption);
+ }}>
+
+ {label}
+ {required && (
+
+ {' '}
+ *
+
+ )}
+
+
+
- (option as unknown as TypeaheadOption)?.label
- }
- required={required}
- onChange={(event) => {
- setQuery(event.target.value);
- onQueryChange(event.target.value, event);
- }}
- {...props}
- />
-
-
+
+ (option as unknown as TypeaheadOption)?.label
+ }
+ required={required}
+ onChange={(event) => {
+ setQuery(event.target.value);
+ onQueryChange(event.target.value, event);
+ }}
+ {...props}
/>
-
+
+
+
+
+
setQuery('')}
+ as={Fragment}
+ leave="transition ease-in duration-100"
+ leaveFrom="opacity-100"
+ leaveTo="opacity-0">
+
+ {options.length === 0 && query !== '' ? (
+
+ {noResultsMessage}
+
+ ) : (
+ options.map((option) => (
+
+ clsx(
+ 'relative cursor-default select-none py-2 px-4 text-slate-500',
+ active && 'bg-slate-100',
+ )
+ }
+ value={option}>
+ {({ selected }) => (
+
+ {option.label}
+
+ )}
+
+ ))
+ )}
+
+
- setQuery('')}
- as={Fragment}
- leave="transition ease-in duration-100"
- leaveFrom="opacity-100"
- leaveTo="opacity-0">
-
- {options.length === 0 && query !== '' ? (
-
- {noResultsMessage}
-
- ) : (
- options.map((option) => (
-
- clsx(
- 'relative cursor-default select-none py-2 px-4 text-slate-500',
- active && 'bg-slate-100',
- )
- }
- value={option}>
- {({ selected }) => (
-
- {option.label}
-
- )}
-
- ))
- )}
-
-
-
-
+
+
);
}
diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx
index 1a7d77fa..da2ecf15 100644
--- a/packages/ui/src/index.tsx
+++ b/packages/ui/src/index.tsx
@@ -4,6 +4,9 @@ export { default as Alert } from './Alert/Alert';
// Badge
export * from './Badge/Badge';
export { default as Badge } from './Badge/Badge';
+// Banner
+export * from './Banner/Banner';
+export { default as Banner } from './Banner/Banner';
// Button
export * from './Button/Button';
export { default as Button } from './Button/Button';
diff --git a/tatus b/tatus
new file mode 100644
index 00000000..02594b3b
--- /dev/null
+++ b/tatus
@@ -0,0 +1,6907 @@
+[33mcommit 72572b5c726e05512071166507a02a39016594cc[m[33m ([m[1;36mHEAD -> [m[1;32mhongpo/refactor-question-routers[m[33m, [m[1;31morigin/hongpo/refactor-question-routers[m[33m)[m
+Merge: c20c0e0 f48a34e
+Author: hpkoh
+Date: Wed Oct 26 16:47:23 2022 +0800
+
+ Merge branch 'main' of https://github.com/yangshun/tech-interview-handbook into hongpo/refactor-question-routers
+
+[33mcommit c20c0e0eca1e73b00a4ab0934148fe7357006d86[m
+Author: hpkoh
+Date: Wed Oct 26 16:42:54 2022 +0800
+
+ [questions][chore] Refactor routers
+
+[33mcommit f48a34e88afc03aaba3b1ab3c1fc0275d1f259f9[m
+Author: Yangshun Tay
+Date: Wed Oct 26 16:30:06 2022 +0800
+
+ [portal] prettify files
+
+[33mcommit 84c3caeb1cdb5b056798656d97b7c4b674ee9b9d[m
+Author: Yangshun Tay
+Date: Wed Oct 26 16:27:05 2022 +0800
+
+ [portal][nav] change navbar font weights
+
+[33mcommit a7e07008b8f9c8536abe61cd5b015e0924606044[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Wed Oct 26 16:23:28 2022 +0800
+
+ [resumes][feat] Add About Us page (#431)
+
+ * [resumes][feat] Add About Us page
+
+ * [resumes][fix] update header
+
+ * [resumes][chore] remove for fun
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit 0ba2815fbdc81d1fe480010e9027975da5c7db5c[m
+Author: Keane Chan
+Date: Wed Oct 26 16:23:14 2022 +0800
+
+ [portal] change top nav bar (#433)
+
+[33mcommit 352f8a03ad5923797ffc601263158ca31c07d2c3[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Wed Oct 26 15:38:28 2022 +0800
+
+ [questions][feat] add encounters transaction for crud (#409)
+
+ * [questions][chore] refactor question queries
+
+ * [questions][chore] destructure values from input
+
+ * [questions][feat] add sorting
+
+ * [question][fix] fix frontend
+
+ * [questions][feat] add sorting
+
+ * [questions][feat] add sorting index
+
+ * [questions][chore] push migration file
+
+ * [questions][fix] fix ci issues
+
+ * [questions][fix] fix import errors
+
+ * [questions][feat] add encounters transaction for crud
+
+ * [questions][chore] fix import
+
+ * [questions][chore] update error handling
+
+ * [questions][feat] parallelize queries
+
+ * [questions][fix] update to use corrcet client
+
+ * Update questions-question-encounter-router.ts
+
+ * Update questions-question-encounter-router.ts
+
+ Co-authored-by: Jeff Sieu
+
+[33mcommit fa5cf0c115b0a972c7c0a6c9099ea43252d7cc3e[m
+Author: Su Yin <53945359+tnsyn@users.noreply.github.com>
+Date: Wed Oct 26 13:18:51 2022 +0800
+
+ [resumes][fix] Fix profile popup issue (#432)
+
+[33mcommit 05119f52fa9a8c036ccad0783a5fb6aa9221de3e[m
+Author: Wu Peirong
+Date: Tue Oct 25 18:46:17 2022 +0800
+
+ [resumes][fix] browse ui and sort order labels
+
+[33mcommit 199fc1a8b999524ecca1c69a74ff9527c910420d[m
+Author: Peirong <35712975+peironggg@users.noreply.github.com>
+Date: Tue Oct 25 12:08:07 2022 +0800
+
+ [resumes][feat] url search params (#429)
+
+ * [resumes][feat] adapt useSearchParams
+
+ * [resumes][feat] clickable button from review info tags
+
+[33mcommit db19a84080db7421dca1039d7ca27db93a0cddc6[m
+Author: Bryann Yeap Kok Keong
+Date: Tue Oct 25 10:40:55 2022 +0800
+
+ [offers][chore] Speed up fetching of dashboard offers
+
+[33mcommit 000f22a50c86649f8647a1017cf6232cf6deee60[m
+Author: Keane Chan
+Date: Tue Oct 25 10:38:43 2022 +0800
+
+ [resumes][feat] center text in file drop
+
+[33mcommit 79b62234ead68c492381bac50e3ee9550868f4f3[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Tue Oct 25 10:29:01 2022 +0800
+
+ Revert "[questions][feat] add text search (#412)" (#428)
+
+ This reverts commit f70caba3f20e4d224e6e86aad9b05b66375d8aa3.
+
+[33mcommit 3b4cba377169ea822643da38f2a92d8da2533f80[m
+Author: Keane Chan
+Date: Tue Oct 25 09:57:06 2022 +0800
+
+ [resumes][feat] mobile responsiveness for form
+
+[33mcommit de28a300287f829cb0fb10922c3f50cf14fd78b9[m
+Author: Keane Chan
+Date: Tue Oct 25 09:40:58 2022 +0800
+
+ [resumes][feat] shift badge to bottom
+
+[33mcommit ef6179361667cc6bb060fa748c376c864d4366f7[m
+Author: Ai Ling <50992674+ailing35@users.noreply.github.com>
+Date: Tue Oct 25 03:31:46 2022 +0800
+
+ [offers][fix] Fix offers form experience section (#427)
+
+[33mcommit f70caba3f20e4d224e6e86aad9b05b66375d8aa3[m[33m ([m[1;32mmain[m[33m)[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Tue Oct 25 01:22:05 2022 +0800
+
+ [questions][feat] add text search (#412)
+
+[33mcommit 7589e9b078f1841c96fd923f978a3d21d6cd0f99[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Tue Oct 25 01:21:34 2022 +0800
+
+ [questions][feat] add list crud (#393)
+
+ Co-authored-by: Jeff Sieu
+
+[33mcommit ce906c0470bbd4454ddc39c8c39ab0f0c3d35246[m
+Author: Keane Chan
+Date: Tue Oct 25 00:27:54 2022 +0800
+
+ [ui][text input] change pl to px for startAddOn
+
+[33mcommit f8423afe2a7ca708284b1444e9fe7b751267100e[m
+Author: Ai Ling <50992674+ailing35@users.noreply.github.com>
+Date: Tue Oct 25 00:24:44 2022 +0800
+
+ [offers][fix] Fix UI and remove specialization on the backend (#426)
+
+ * [offers][fix] Remove specialization on the frontend
+
+ * [offers][fix] Rename refresh OEA button
+
+ * [offers][chore] Remove specialisation and make bonus, stocks and baseSalary optional
+
+ * [offers][fix] Fix OEA profile job title
+
+ Co-authored-by: Bryann Yeap Kok Keong
+
+[33mcommit 4de0a1f681d8ed8bfe073a3ffcc3f010d3f71d25[m
+Author: Keane Chan
+Date: Mon Oct 24 23:56:14 2022 +0800
+
+ [resumes][feat] improve badge UI
+
+[33mcommit 98e422953c397b60c0156a8bcea7a7e4f32e0f6a[m
+Author: Keane Chan
+Date: Mon Oct 24 23:56:04 2022 +0800
+
+ [resumes][feat] add shadow to buttons
+
+[33mcommit c118ed59d490e9cc6dc66650d695b5059c80a10a[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Mon Oct 24 23:20:51 2022 +0800
+
+ [questions][fix] fix pagination off by one (#425)
+
+[33mcommit 0d53dab7a835f40cd974c91abb6c17bd593372df[m
+Author: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com>
+Date: Mon Oct 24 23:18:23 2022 +0800
+
+ [offers][fix] fix landing page description (#424)
+
+ * [offers][fix] fix landing page width
+
+ * [offers][fix] fix landing page typo
+
+ * [offers][chore] fix British English in landing page
+
+ * [offers][chore] fix description in landing page
+
+[33mcommit 64670923e15a080356edba62caa4f71879fa9ee0[m
+Author: Keane Chan
+Date: Mon Oct 24 23:00:50 2022 +0800
+
+ [resumes][feat] add error message to submit form
+
+[33mcommit b52f15a937a0cc4b90fddd76c42638a22a3b7cb6[m
+Author: Keane Chan
+Date: Mon Oct 24 23:00:37 2022 +0800
+
+ [portal][ui] add error message to checkbox input
+
+[33mcommit 471a28be8ae06cc3513fd2a17435626cefa9cbf2[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Mon Oct 24 22:56:50 2022 +0800
+
+ [questions][feat] pagination (#410)
+
+ * [questions][feat] pagination
+
+ * [questions][feat] update aggregated data
+
+ * [questions][feat] add next cursors
+
+ * [questions][fix] fix bug
+
+ * [questions][chore] fix lint error
+
+ * [questions][chore] update cursor to support adapter
+
+ * [questions][feat] paginate browse queries
+
+ * [questions][ui] change page size to 10
+
+ * [question][refactor] clean up router code
+
+ * [questions][fix] fix type errors
+
+ * [questions][feat] add upvotes tracking
+
+ * [questions][chore] add default upovte value
+
+ Co-authored-by: Jeff Sieu
+
+[33mcommit bf35f97961306fa3dad817309cac0e48dd7b289b[m
+Author: Ai Ling <50992674+ailing35@users.noreply.github.com>
+Date: Mon Oct 24 22:34:28 2022 +0800
+
+ [offers][fix] Use title typeahead, add default currency and remove specialization field (#423)
+
+[33mcommit 64cd69d0243c8f025cee8db21116f2b969561e57[m
+Author: Keane Chan
+Date: Mon Oct 24 21:55:21 2022 +0800
+
+ [resumes][feat] move badge to the right instead
+
+[33mcommit e5c2082bf23c2b04cff99fdd5977a30970937c92[m
+Author: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com>
+Date: Mon Oct 24 22:00:48 2022 +0800
+
+ [offers][feat] add landing page and fix comment bugs (#422)
+
+ * [offers][fix] fix create commnet and update title
+
+ * [offers][fix] update tab name
+
+ * [offers][feat] add landing page
+
+[33mcommit c188405de03da34a93346a8a26bdb8e6ff2fedfa[m
+Author: Stuart Long Chay Boon
+Date: Mon Oct 24 21:39:49 2022 +0800
+
+ [offers][chore] create random string generator for token
+
+[33mcommit 3c8029625355c29d090596a3e401e9cc2a8f545e[m
+Author: Stuart Long Chay Boon
+Date: Mon Oct 24 21:27:40 2022 +0800
+
+ [offers][refactor] refactor random name generator code
+
+[33mcommit e77bb0363982209806b7480e00e252140c3849cd[m
+Author: Stuart Long Chay Boon
+Date: Mon Oct 24 21:09:33 2022 +0800
+
+ [offers][chore] integrate random name generator to create profile
+
+[33mcommit ef5892a0d684015da3da4d9a428867e0642332ea[m
+Author: Stuart Long Chay Boon
+Date: Mon Oct 24 20:56:28 2022 +0800
+
+ [offers][feat] add random name generator
+
+[33mcommit 65a8618e547b478773b509f08626a9dfbb0de02a[m
+Author: Bryann Yeap Kok Keong
+Date: Mon Oct 24 18:40:20 2022 +0800
+
+ [offers][chore] Add a secondary sorting by monthYearReceived descending in addition to other primary sorting filters applied
+
+[33mcommit 3f6ae58374329db1098e0529bbef52c657132f68[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Mon Oct 24 16:02:41 2022 +0800
+
+ [resumes][refactor] Update resume review page UI (#418)
+
+ * [resumes][refactor] update comments ui
+
+ * [resumes][refactor] change comment border color
+
+ * [resumes][refactor] update review ui
+
+ * [resumes][refactor] rearrange review page
+
+ * update add review button
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit 70b102f87e99fc66c43462b44909b48f99843f27[m
+Author: Keane Chan
+Date: Mon Oct 24 14:59:24 2022 +0800
+
+ [resumes][fix] fix unauthenticated issue on submit form
+
+[33mcommit 8e50cc7313e0a34e9fdf0e8b47e8e59f457032c7[m
+Author: Su Yin <53945359+tnsyn@users.noreply.github.com>
+Date: Mon Oct 24 14:52:19 2022 +0800
+
+ [resumes][fix] Fix browse page scrolling UI (#421)
+
+ * Fix browse page styling comments
+
+ * [resumes][fix] Fix search issue
+
+ * [resumes][fix] Make styling changes
+
+[33mcommit e64d645d36dca8375715d26e96a83f346e02a6d6[m
+Author: Keane Chan
+Date: Mon Oct 24 14:29:59 2022 +0800
+
+ [resumes][feat] update submit page
+
+[33mcommit 5844c52efef6040bc57d648d39fbcf719f96e008[m
+Author: Yangshun Tay
+Date: Mon Oct 24 13:15:47 2022 +0800
+
+ [portal] add required field for companies typeahead
+
+[33mcommit 4e0e9d0f9ea0d0b0d5ca81f7f59dcfb599af6ab0[m
+Author: Yangshun Tay
+Date: Mon Oct 24 13:15:31 2022 +0800
+
+ [portal] add job titles typeahead
+
+[33mcommit f25a4d453203ead1e698e72a03621028c37ea362[m
+Author: Yangshun Tay
+Date: Mon Oct 24 10:16:24 2022 +0800
+
+ [misc] prettify files
+
+[33mcommit a4e63b8a41ec063e784c3abd952597694fb73b54[m
+Author: Yangshun Tay
+Date: Mon Oct 24 10:15:03 2022 +0800
+
+ [portal] standardize colors
+
+[33mcommit 82f2857667c682a078bcd662a6d8d2fc2ee10446[m
+Author: Yangshun Tay
+Date: Mon Oct 24 09:53:30 2022 +0800
+
+ [ui][typeahead] fix results showing below other stacked elements by adding z-index
+
+[33mcommit d9af66152cb395ecdac857e64078447650b006a9[m
+Author: Yangshun Tay
+Date: Mon Oct 24 09:35:22 2022 +0800
+
+ [ui][text input] fix input add on disappearing when width is too small
+
+[33mcommit 94f232f67c97b780cc9927281e34486ea267b328[m
+Author: Yangshun Tay
+Date: Mon Oct 24 09:20:27 2022 +0800
+
+ [ui] change to text-sm for some elements
+
+[33mcommit 5f546f951f7ae6c8cb2d99579eab480d14bd8a38[m
+Author: Bryann Yeap Kok Keong
+Date: Mon Oct 24 06:18:14 2022 +0800
+
+ [offers][fix] Remove crypto coin from currencies
+
+[33mcommit 34538919b1babe04be5058ffadcefa4d7c6f5a98[m
+Author: Stuart Long Chay Boon
+Date: Mon Oct 24 01:30:30 2022 +0800
+
+ [offers][fix] fix edit profile endpoint
+
+[33mcommit 26055d2ed0ebb5058fda79aa199ead6512726b1b[m
+Author: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com>
+Date: Mon Oct 24 00:36:05 2022 +0800
+
+ [offers][fix] remove dark theme for table (#420)
+
+[33mcommit b7f4cf93a0ff28a431215a6bf94aee061a249d40[m
+Author: Peirong <35712975+peironggg@users.noreply.github.com>
+Date: Sun Oct 23 22:37:16 2022 +0800
+
+ [resumes][fix] search and pagination bugs (#419)
+
+[33mcommit c3d2b4d3256de222dd711d666c24074f253fd0f9[m
+Author: Ai Ling <50992674+ailing35@users.noreply.github.com>
+Date: Sun Oct 23 18:51:12 2022 +0800
+
+ [offers][feat] Add toast (#417)
+
+ * [offers][feat] Add toasts
+
+ * [offers][fix] Disable empty comments
+
+[33mcommit c0f92584ef1db397fb19eb24341a3972066ea7af[m
+Author: Ai Ling <50992674+ailing35@users.noreply.github.com>
+Date: Sun Oct 23 18:00:36 2022 +0800
+
+ [offers][feat] Add analysis to offers profile page (#416)
+
+[33mcommit 7c63e22b3a67c16a3a115d5a447c067b6715f359[m
+Author: Keane Chan
+Date: Sun Oct 23 17:49:47 2022 +0800
+
+ [resumes][feat] hide scrollbar
+
+[33mcommit db32fe0f6780515dbc303701e01761e6e776aa1c[m
+Author: Keane Chan
+Date: Sun Oct 23 17:07:08 2022 +0800
+
+ [resumes][feat] remove resume title, clean up submit form (#415)
+
+ * [resumes][refactor] clean up submit form
+
+ * [resumes][feat] remove resume title
+
+ * [resumes][feat] remove resume title
+
+[33mcommit 7bd6b0eeac448b7a0f67c919c92e80047a61f045[m
+Author: Keane Chan
+Date: Sun Oct 23 13:58:30 2022 +0800
+
+ [resumes][feat] update submit form to be more compact (#414)
+
+[33mcommit 77d0714e33b462eb03ce8a9be30c847a8452cf31[m
+Author: Ai Ling <50992674+ailing35@users.noreply.github.com>
+Date: Sun Oct 23 01:35:03 2022 +0800
+
+ [offers][fix] Refactor and fix offer analysis (#413)
+
+[33mcommit 6bf1a60bbd19ecad48152037b294af0e80b6ee1d[m
+Author: Keane Chan
+Date: Sun Oct 23 01:01:34 2022 +0800
+
+ [resumes][feat] show pagination only when required
+
+[33mcommit ce185607dbd101c0839b65631ef3c0ba60a91157[m
+Author: Keane Chan
+Date: Sun Oct 23 01:00:23 2022 +0800
+
+ [resumes][feat] use pagination component for resume pdf
+
+[33mcommit 862bb53cdb3b618f2e3606ac3ce3d03badcdd49e[m
+Author: Keane Chan
+Date: Sun Oct 23 01:00:04 2022 +0800
+
+ [resumes][feat] change to most comments
+
+[33mcommit 11aa89353fd8fccf157fb3166f186bd62c9b7b76[m
+Author: Jeff Sieu
+Date: Sat Oct 22 22:14:19 2022 +0800
+
+ [questions][feat] add lists ui, sorting, re-design landing page (#411)
+
+ Co-authored-by: wlren
+
+[33mcommit 508eea359e7528186c51c2b02f202145bde79293[m
+Author: Wu Peirong
+Date: Sat Oct 22 19:16:39 2022 +0800
+
+ [resumes][chore] add screenshots to landing
+
+[33mcommit a5bdb728906f3c66cdceabf66571dd0ea1ee1c54[m
+Merge: b0329a0 e55d082
+Author: Stuart Long Chay Boon
+Date: Sat Oct 22 17:55:27 2022 +0800
+
+ Merge branch 'main' of https://github.com/yangshun/tech-interview-handbook
+
+[33mcommit b0329a04f03286c3be6225447bed888f8dbd4553[m
+Author: Stuart Long Chay Boon
+Date: Sat Oct 22 17:55:14 2022 +0800
+
+ [offers][fix] remove compulsory tc and monthly salary for past exp
+
+[33mcommit e55d08279bce7362957673fee9f76bda03342f47[m
+Author: Yangshun Tay
+Date: Sat Oct 22 17:46:35 2022 +0800
+
+ [ui] add toasts
+
+[33mcommit 6948c2e4ee11d0ba44d2382b569abd63a4737fd6[m
+Author: Bryann Yeap Kok Keong
+Date: Sat Oct 22 16:25:26 2022 +0800
+
+ [offers][refactor] Rename currency exchanger file to follow camelCase
+
+[33mcommit 78a7e884104c7e1d64694f5e7e61502818dd62aa[m
+Author: Bryann Yeap Kok Keong
+Date: Sat Oct 22 16:13:38 2022 +0800
+
+ [offers][chore] Add monthYearReceived to the analysis generation API
+
+[33mcommit b37ce69c25f115b249e05b54342d4e039276e992[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Sat Oct 22 14:31:29 2022 +0800
+
+ [questions][feat] update question filter (#384)
+
+ * [questions][chore] refactor question queries
+
+ * [questions][chore] destructure values from input
+
+ * [questions][feat] add sorting
+
+ * [question][fix] fix frontend
+
+ * [questions][feat] add sorting
+
+ * [questions][feat] add sorting index
+
+ * [questions][chore] push migration file
+
+ * [questions][fix] fix ci issues
+
+ * [questions][fix] fix import errors
+
+ Co-authored-by: Jeff Sieu
+
+[33mcommit 5e6482aa2e5b3c7aff8fa6b0b35bb74111007c9d[m
+Author: Bryann Yeap Kok Keong
+Date: Sat Oct 22 13:44:36 2022 +0800
+
+ [offers][fix] Add previous companies to analysis DTO
+
+[33mcommit be594c7513c0e9d96362de62ed0602032bd7d6a9[m
+Author: Bryann Yeap Kok Keong
+Date: Sat Oct 22 13:24:45 2022 +0800
+
+ [offers][fix] Fix analysis and offers API to accommodate new fields in OffersCurrency
+
+[33mcommit bb97c4dea6907e77d3b339fde58b0245d86bc445[m
+Author: Wu Peirong
+Date: Sat Oct 22 10:23:26 2022 +0800
+
+ [resumes][fix] fix nouns singular/plural (s)
+
+[33mcommit b2237f97f274d9927a4f5d40896c1962ac6fcaf5[m
+Author: Wu Peirong
+Date: Sat Oct 22 10:05:15 2022 +0800
+
+ [resumes][feat] add spinner to browse page
+
+[33mcommit 13f40ab6ae89196f3695a986a1bad51b9bbb6e79[m
+Author: Bryann Yeap Kok Keong
+Date: Sat Oct 22 08:01:29 2022 +0800
+
+ [offers][chore] Add baseCurrency and baseValue to Valuation DTO
+
+[33mcommit 2c7f349043414bc01fce09f99d753527506dd050[m
+Author: Bryann Yeap Kok Keong
+Date: Sat Oct 22 07:48:07 2022 +0800
+
+ [offers][chore] Add a relative base value to the currency model in schema
+
+[33mcommit f8d22632ec8131ee842f640f92e3c57338cd28b6[m
+Merge: b345ae0 2414deb
+Author: Bryann Yeap Kok Keong
+Date: Sat Oct 22 00:34:04 2022 +0800
+
+ Merge branch 'main' of github.com:yangshun/tech-interview-handbook
+
+ * 'main' of github.com:yangshun/tech-interview-handbook:
+ [resumes][feat] use overflow-auto instead
+ [resumes][fix] invalidate fetch query on submit
+ [resumes][fix] fix reply comments (#407)
+
+[33mcommit b345ae0c8fdd66212ad82232166a8017835655c5[m
+Author: Bryann Yeap Kok Keong
+Date: Sat Oct 22 00:33:49 2022 +0800
+
+ [offers][refactor] Refactor the sorting to use prisma's ORDERBY api
+
+[33mcommit 2414deb62452aa15e57ef09837287db39214c6a3[m
+Author: Keane Chan
+Date: Sat Oct 22 00:09:48 2022 +0800
+
+ [resumes][feat] use overflow-auto instead
+
+[33mcommit 7d0dba966968d9a5f6c15fee769ba03e5e9b633a[m
+Author: Keane Chan
+Date: Sat Oct 22 00:09:27 2022 +0800
+
+ [resumes][fix] invalidate fetch query on submit
+
+[33mcommit dac178e712d91f243ae70a2b268e5bf07d384a09[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Fri Oct 21 23:14:57 2022 +0800
+
+ [resumes][fix] fix reply comments (#407)
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit 2729e20351c97940f0bdabf66ecbfdbfd60bbaeb[m
+Author: Bryann Yeap Kok Keong
+Date: Fri Oct 21 23:12:55 2022 +0800
+
+ [offers][refactor] Refactor the sorting to use prisma's WHERE api
+
+[33mcommit 8b8fffdab188b57382141134d8e2171b6b1e59e4[m
+Author: Su Yin <53945359+tnsyn@users.noreply.github.com>
+Date: Fri Oct 21 22:18:12 2022 +0800
+
+ [resumes][feat] Add mobile filters (#408)
+
+[33mcommit d200793d20e95d5f9b97a31c645b703d1b781604[m
+Author: Bryann Yeap Kok Keong
+Date: Fri Oct 21 21:50:01 2022 +0800
+
+ [offers][feat] Allowing showing income based on selected salary
+
+[33mcommit 587e80b1bf6fca971cacb63dda7d89e6a0d590a0[m
+Author: Wu Peirong
+Date: Fri Oct 21 20:52:32 2022 +0800
+
+ [resumes][fix] unauthenticated browse page show only sign-in prompt
+
+[33mcommit f123ffa7e2fd052b40a8b8639a0c6629618d55db[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Fri Oct 21 17:56:35 2022 +0800
+
+ [resumes][refactor] Comment UI touchup (#405)
+
+ * [resumes][refactor] add vertical line to replies
+
+ * [resumes][refactor] sort replies ascending
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit 817f1d57052db654388289d887436ce725e2e31e[m
+Author: Stuart Long Chay Boon
+Date: Fri Oct 21 16:28:24 2022 +0800
+
+ [offers][chore] add remove offers/experience/education/specificyoe in editprofile
+
+[33mcommit 35494dc7eafc40bcf846136c10c2633533bb368c[m
+Author: Keane Chan
+Date: Fri Oct 21 15:56:40 2022 +0800
+
+ [resumes][fix] fix expandable text bug
+
+[33mcommit d10377e0f909bd0a1a263e8e1f05fc6fc1071cc2[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Fri Oct 21 15:55:59 2022 +0800
+
+ [resumes][feat] replying comments (#401)
+
+ * [resumes][feat] add resume comment parent
+
+ * [resumes][refactor] Abstract comment edit form and votes to their components
+
+ * [resumes][feat] Add reply form
+
+ * [resumes][feat] Render replies
+
+ * [resumes][feat] add collapsible comments
+
+ * [resumes][chore] remove comment
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit 6a665bc9760197dc36a4b7afad32b4b9f173199f[m
+Author: Stuart Long Chay Boon
+Date: Fri Oct 21 15:46:23 2022 +0800
+
+ [offers][chore] remove profileName and discussion from editprofile
+
+[33mcommit 18d2a10708ff13deb83aa65814db332c73989420[m
+Author: Keane Chan
+Date: Fri Oct 21 15:33:07 2022 +0800
+
+ [resumes][feat] update top upvoted comment count
+
+[33mcommit 22d5f54a47319ae1b35b7eb4302762776ab17291[m
+Author: Keane Chan
+Date: Fri Oct 21 15:25:14 2022 +0800
+
+ [resumes][feat] add padding and hide scrollbar for comments (#404)
+
+ * [resumes][feat] add padding and hide scrollbar for comments
+
+ * [resumes][feat] add findUserTopUpvotedCommentCount query
+
+[33mcommit fc93596c3996ac2de2958601688789adf8a471ad[m
+Author: BryannYeap
+Date: Fri Oct 21 13:46:36 2022 +0800
+
+ Return currency with income in analysis top offers
+
+[33mcommit 910cc151489d805276c0f0c0ded0e687ba66436f[m
+Author: Keane Chan
+Date: Fri Oct 21 10:17:43 2022 +0800
+
+ [resumes][fix] fix tooltip
+
+[33mcommit 11df1e1f1ceb83bd4d99ca7a57abe2170592fbfa[m
+Author: Ai Ling <50992674+ailing35@users.noreply.github.com>
+Date: Fri Oct 21 02:19:29 2022 +0800
+
+ [offers][feat] Integrate offers profile edit (#403)
+
+ * [offers][fix] Fix offer analysis and save
+
+ * [offers][fix] Fix profile view page
+
+ * [offers][feat] Add offers profile edit
+
+[33mcommit 0adec461d091cb24f34d9498ef706cb68634d854[m
+Author: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com>
+Date: Thu Oct 20 23:39:07 2022 +0800
+
+ [offers][feat] add filters for table and fix pagination (#402)
+
+ * [offers][feat] add filters for table and fix pagination
+
+ * [offers][fix] display currency
+
+[33mcommit 7c467d2e0e2ea9ece8e0260915716ab9fdf0bea9[m
+Author: Wu Peirong
+Date: Thu Oct 20 22:35:52 2022 +0800
+
+ [resumes][feat] add helpful text when no resume shown
+
+[33mcommit 452686760140368775dfb8a7da76081dc852deee[m
+Author: Keane Chan
+Date: Thu Oct 20 22:15:54 2022 +0800
+
+ [resumes][feat] add query for max resume upvote count
+
+[33mcommit 10d23fe4649c8ef018484dc99ac61b30eacb8138[m
+Author: Keane Chan
+Date: Thu Oct 20 22:01:08 2022 +0800
+
+ [resumes][feat] add resume badges
+
+[33mcommit 89f55bc132e34456e75fcca3d04e94db64aacf2c[m
+Author: Wu Peirong
+Date: Thu Oct 20 21:30:15 2022 +0800
+
+ [resumes][feat] add useDebounceValue hook
+
+[33mcommit 707161380ff87a18ce6ba732e303a25104a067c9[m
+Author: Keane Chan
+Date: Thu Oct 20 20:47:46 2022 +0800
+
+ [resumes][feat] fix resume badge UI (#400)
+
+ * [resumes][feat] update badge icon
+
+ * [resumes][refactor] update resume badge names
+
+ * [resumes][refactor] update to title
+
+ * [resumes][fix] disable horizontal scroll in comments
+
+[33mcommit 0311ee3e6ad51146e717495496684f5ef28fdb6c[m
+Author: BryannYeap
+Date: Thu Oct 20 20:02:19 2022 +0800
+
+ [offers][chore] Add location to experience in the back end
+
+[33mcommit 111b0781472417e8b02b977bf8a86623d0c5efac[m
+Author: BryannYeap
+Date: Thu Oct 20 19:40:26 2022 +0800
+
+ [offers][fix] Fix the sort and filter checks in list offers API
+
+[33mcommit 283333e1ee197c470473046989203a46d70211d9[m
+Author: Wu Peirong
+Date: Thu Oct 20 19:26:26 2022 +0800
+
+ [resumes][fix] browse tabs updates on tab shift
+
+[33mcommit 41d51702252a6d736d09532858239b9532e1cae0[m
+Author: Keane Chan
+Date: Thu Oct 20 18:25:26 2022 +0800
+
+ [resumes][feat] scaffold for resume badges (#399)
+
+ * [resumes][fix] reduce font size in comments
+
+ * [resumes][feat] add queries for resume badges
+
+ * [resumes][feat] add scaffold for resume badges
+
+ * [resumes][chore] remove unused query
+
+[33mcommit a5c300c9b24cc9778c7332878f85b84546bbc02f[m
+Author: BryannYeap
+Date: Thu Oct 20 18:12:30 2022 +0800
+
+ [offers][fix] Align the range of Junior, Mid, and Senior SWE in the backend with the frontend
+
+[33mcommit 9741bf83b9dda30c1165475875640140cf945262[m
+Author: Wu Peirong
+Date: Thu Oct 20 17:54:36 2022 +0800
+
+ [resumes][refactor] add staleTime to browse page queries
+
+[33mcommit 94e2b1c01ed5f30fa63572498104e07184fe0c21[m
+Author: Wu Peirong
+Date: Thu Oct 20 11:05:26 2022 +0800
+
+ [resumes][feat] add reviewer badge icons
+
+[33mcommit 992d457b8a4431f97bbbd8c0158ce59ab0290ea1[m
+Author: Ai Ling <50992674+ailing35@users.noreply.github.com>
+Date: Thu Oct 20 02:46:50 2022 +0800
+
+ [offers][feat] Integrate offers analysis into offers submission (#398)
+
+ * [offers][fix] Fix minor issues in form
+
+ * [offers][fix] Use companies typeahead in form
+
+ * [offers][feat] Fix types and integrate offers analysis
+
+ * [offers][fix] Fix generate analysis API test
+
+[33mcommit 4fa350964f086d63b2fdfc6b281a5debe73abdd3[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Thu Oct 20 00:32:42 2022 +0800
+
+ [questions][feat] add question encounter crud (#343)
+
+[33mcommit c8b1e4333758de1f26cad173c54372998de304e7[m
+Author: Keane Chan
+Date: Wed Oct 19 21:56:02 2022 +0800
+
+ [resumes][feat] misc updates (#397)
+
+ * [resumes][feat] only load comments on initial fetch
+
+ * [resumes][feat] update dropzone for form
+
+[33mcommit cf1852a3021a922bab6f90da8e0ad93e9965d60f[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Wed Oct 19 20:28:07 2022 +0800
+
+ [resumes][feat] Update vote animation (#396)
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit a879639b531ee2a7a6d6620c450541db067bfd83[m
+Author: Stuart Long Chay Boon
+Date: Wed Oct 19 18:47:03 2022 +0800
+
+ [offers][chore] add migration sql and change naming conventions
+
+[33mcommit a53c10483e38cc3efeaa0319f6bb2e34db30a7d7[m
+Author: Su Yin <53945359+tnsyn@users.noreply.github.com>
+Date: Wed Oct 19 18:37:41 2022 +0800
+
+ [resumes][feat] Add pagination on browse page (#388)
+
+ * [resumes][feat] Add pagination on browse page
+
+ * [resume][fix] Remove unused type
+
+[33mcommit d8213639d354e336783816156ea7f8a099f95aaf[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Wed Oct 19 18:14:33 2022 +0800
+
+ add spinner (#394)
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit 2f12a900e6a6bc0ef9e006d08bf39c8a811639ac[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Wed Oct 19 18:08:51 2022 +0800
+
+ [questions][chore] update to use company table values (#351)
+
+ Co-authored-by: Jeff Sieu
+
+[33mcommit 410bf290c915f568f64b29c9cd11ec51599f321f[m
+Author: peirong.wu
+Date: Wed Oct 19 18:02:09 2022 +0800
+
+ [resumes][chore] disable pdf highlighting
+
+[33mcommit 1ed11d978786336d0261408cca636cb98a1c0ede[m
+Author: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com>
+Date: Wed Oct 19 17:43:10 2022 +0800
+
+ [offers][feat] offer discussion section (#392)
+
+ * [offers][feat] add comment components
+
+ * [offers][feat] add comment reply components
+
+ * [offers][feat] offer discussion section
+
+ * [offers][chore] remove comments
+
+[33mcommit bde445859ab90c5a3ad75c13c2b58ec7b912cf98[m
+Author: Stuart Long Chay Boon
+Date: Wed Oct 19 17:06:06 2022 +0800
+
+ [offers][chore] return comments in reverse chronological order
+
+[33mcommit 22805022a99a012acdd466680e172749838015b4[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Wed Oct 19 16:48:03 2022 +0800
+
+ [resumes][feat] Add resume comment upvote/downvote (#389)
+
+ * [resumes][feat] Add upvote/downvote
+
+ * [resumes][refactor] abstract comment votes fetching from comments
+
+ * [resumes][chore] remove votes from comments query
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit 925ba937b4fa03cf5dd12bddb6b4dedec61cfdc4[m
+Author: hpkoh <53825802+hpkoh@users.noreply.github.com>
+Date: Wed Oct 19 15:12:56 2022 +0800
+
+ [questions][refactor]: refactor queries (#383)
+
+[33mcommit 3209f8ef7e7caffdc31de514db66f9d15a945a3f[m
+Author: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com>
+Date: Wed Oct 19 14:46:45 2022 +0800
+
+ [offers][fix] fix types for list some offers (#391)
+
+[33mcommit bc424bee3304b50d68987ad2b0b4551a37400917[m
+Author: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com>
+Date: Wed Oct 19 14:36:38 2022 +0800
+
+ [offers][refactor] add types for interfaces (#390)
+
+ * [offers][chore] Create types for API responses
+
+ * [offers][fix] fix get comments bug
+
+ * [offers][fix] make offers api open to unauthenticated users
+
+ * [offers][chore] add return types to comment api
+
+ * [offers][chore] add types to get comments api
+
+ * [offers][chore] Refactor profile and analysis APIs to return defined types
+
+ * [offers][chore] Add typed response for get offers API
+
+ * [offers][chore] Changed delete offer API response
+
+ * [offers][fix] Fix type definitions for OffersCompany in types/offers
+
+ * [offers][fix] fix list some offer frontend
+
+ Co-authored-by: BryannYeap
+ Co-authored-by: Stuart Long Chay Boon
+
+[33mcommit 612bef14ad4ee72225ca5ec24246468d6716ea66[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Tue Oct 18 21:20:56 2022 +0800
+
+ [resumes][refactor] Change Tabs to List view (#387)
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit 5913a52f2b04ea3c7be9717738ba70d8c16bb39c[m[33m ([m[1;31morigin/deploy[m[33m)[m
+Author: Yangshun Tay
+Date: Tue Oct 18 19:55:19 2022 +0800
+
+ [website][marketing] s/moonchaser/rora
+
+[33mcommit 25039b52decb98268d84aff31969205c671fe226[m
+Author: Terence <45381509+Vielheim@users.noreply.github.com>
+Date: Tue Oct 18 18:45:22 2022 +0800
+
+ [resumes][feat] add comment edit (#386)
+
+ * [resumes][feat] add comment edit
+
+ * [resumes][fix] use react-hook-form validation
+
+ Co-authored-by: Terence Ho <>
+
+[33mcommit c9f7b59d527973e662b679a32f92a8f037f6c669[m
+Author: Wu Peirong
+Date: Tue Oct 18 14:01:25 2022 +0800
+
+ [resumes][refactor] landing page color + font
+
+[33mcommit 7d1ffb988751b5711ebb09f1652eacff973bb849[m
+Author: Keane Chan
+Date: Tue Oct 18 10:25:23 2022 +0800
+
+ [resumes][fix] fix expandable text not updating
+
+[33mcommit 71838f4ac7842c6bd22a8785adb9496cae794eff[m
+Author: Keane Chan
+Date: Tue Oct 18 10:12:17 2022 +0800
+
+ [resumes][feat] improve UI for submit box
+
+[33mcommit b885e3445feee105a1b17cf4e62e6791c0f6bb1c[m
+Author: Keane Chan
+Date: Mon Oct 17 19:07:19 2022 +0800
+
+ [resumes][feat] remove updating of pdf on edit (#385)
+
+ * [resumes][feat] remove updating of pdf on edit
+
+ * [resumes][fix] remove nit
+
+[33mcommit 9f24e0bcca2f386479b5966832c2ccd920a9a6cc[m
+Author: Wu Peirong
+Date: Sun Oct 16 17:15:33 2022 +0800
+
+ [resumes][feat] implement filter shortcuts
+
+[33mcommit 4d22edabd0dd5f1b85ec36ef94a7b7e643ff59da[m
+Author: Wu Peirong
+Date: Sat Oct 15 23:27:46 2022 +0800
+
+ [resumes][refactor] standardise upload date formatting
+
+[33mcommit 966cf2e8d6f69f09e9d9bebaaa189b495281e795[m
+Author: Wu Peirong