[questions][feat] sort answers, comments (#457)

Co-authored-by: Jeff Sieu <jeffsy00@gmail.com>
pull/466/head
hpkoh 2 years ago committed by GitHub
parent e62c2ae50f
commit 1ea1afc8a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,39 @@
/*
Warnings:
- You are about to drop the column `location` on the `QuestionsQuestionEncounter` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "QuestionsQuestionEncounter" DROP COLUMN "location",
ADD COLUMN "cityId" TEXT,
ADD COLUMN "countryId" TEXT,
ADD COLUMN "stateId" TEXT,
ALTER COLUMN "companyId" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "QuestionsAnswer_updatedAt_id_idx" ON "QuestionsAnswer"("updatedAt", "id");
-- CreateIndex
CREATE INDEX "QuestionsAnswer_upvotes_id_idx" ON "QuestionsAnswer"("upvotes", "id");
-- CreateIndex
CREATE INDEX "QuestionsAnswerComment_updatedAt_id_idx" ON "QuestionsAnswerComment"("updatedAt", "id");
-- CreateIndex
CREATE INDEX "QuestionsAnswerComment_upvotes_id_idx" ON "QuestionsAnswerComment"("upvotes", "id");
-- CreateIndex
CREATE INDEX "QuestionsQuestionComment_updatedAt_id_idx" ON "QuestionsQuestionComment"("updatedAt", "id");
-- CreateIndex
CREATE INDEX "QuestionsQuestionComment_upvotes_id_idx" ON "QuestionsQuestionComment"("upvotes", "id");
-- AddForeignKey
ALTER TABLE "QuestionsQuestionEncounter" ADD CONSTRAINT "QuestionsQuestionEncounter_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuestionsQuestionEncounter" ADD CONSTRAINT "QuestionsQuestionEncounter_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "State"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuestionsQuestionEncounter" ADD CONSTRAINT "QuestionsQuestionEncounter_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE;

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "QuestionsQuestionType" ADD VALUE 'THEORY';

@ -107,27 +107,30 @@ model Company {
}
model Country {
id String @id
name String @unique
code String @unique
states State[]
id String @id
name String @unique
code String @unique
states State[]
questionsQuestionEncounters QuestionsQuestionEncounter[]
}
model State {
id String @id
name String
countryId String
cities City[]
country Country @relation(fields: [countryId], references: [id])
id String @id
name String
countryId String
cities City[]
country Country @relation(fields: [countryId], references: [id])
questionsQuestionEncounters QuestionsQuestionEncounter[]
@@unique([name, countryId])
}
model City {
id String @id
name String
stateId String
state State @relation(fields: [stateId], references: [id])
id String @id
name String
stateId String
state State @relation(fields: [stateId], references: [id])
questionsQuestionEncounters QuestionsQuestionEncounter[]
@@unique([name, stateId])
}
@ -423,6 +426,7 @@ enum QuestionsQuestionType {
CODING
SYSTEM_DESIGN
BEHAVIORAL
THEORY
}
model QuestionsQuestion {
@ -435,12 +439,12 @@ model QuestionsQuestion {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
encounters QuestionsQuestionEncounter[]
votes QuestionsQuestionVote[]
comments QuestionsQuestionComment[]
answers QuestionsAnswer[]
QuestionsListQuestionEntry QuestionsListQuestionEntry[]
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
encounters QuestionsQuestionEncounter[]
votes QuestionsQuestionVote[]
comments QuestionsQuestionComment[]
answers QuestionsAnswer[]
questionsListQuestionEntries QuestionsListQuestionEntry[]
@@index([lastSeenAt, id])
@@index([upvotes, id])
@ -450,14 +454,18 @@ model QuestionsQuestionEncounter {
id String @id @default(cuid())
questionId String
userId String?
// TODO: sync with models (location, role)
companyId String
location String @db.Text
companyId String?
countryId String?
stateId String?
cityId String?
role String @db.Text
seenAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull)
state State? @relation(fields: [stateId], references: [id], onDelete: SetNull)
city City? @relation(fields: [cityId], references: [id], onDelete: SetNull)
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
@ -489,6 +497,9 @@ model QuestionsQuestionComment {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsQuestionCommentVote[]
@@index([updatedAt, id])
@@index([upvotes, id])
}
model QuestionsQuestionCommentVote {
@ -518,6 +529,9 @@ model QuestionsAnswer {
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsAnswerVote[]
comments QuestionsAnswerComment[]
@@index([updatedAt, id])
@@index([upvotes, id])
}
model QuestionsAnswerVote {
@ -546,6 +560,9 @@ model QuestionsAnswerComment {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
votes QuestionsAnswerCommentVote[]
@@index([updatedAt, id])
@@index([upvotes, id])
}
model QuestionsAnswerCommentVote {

@ -28,54 +28,56 @@ export default function ContributeQuestionCard({
};
return (
<button
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
type="button"
onClick={handleOpenContribute}>
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
onChange={handleOpenContribute}
/>
<div className="flex flex-wrap items-end justify-center gap-x-2">
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Company"
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
<div className="w-full">
<button
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
type="button"
onClick={handleOpenContribute}>
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
onChange={handleOpenContribute}
/>
<div className="flex flex-wrap items-end justify-center gap-x-2">
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Company"
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Question type"
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Date"
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>
</div>
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Question type"
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Date"
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>
</div>
<ContributeQuestionDialog
show={showDraftDialog}
onCancel={handleDraftDialogCancel}
onSubmit={onSubmit}
/>
</button>
<ContributeQuestionDialog
show={showDraftDialog}
onCancel={handleDraftDialogCancel}
onSubmit={onSubmit}
/>
</button>
</div>
);
}

@ -0,0 +1,25 @@
import type { UseInfiniteQueryResult } from 'react-query';
import { Button } from '@tih/ui';
export type PaginationLoadMoreButtonProps = {
query: UseInfiniteQueryResult;
};
export default function PaginationLoadMoreButton(
props: PaginationLoadMoreButtonProps,
) {
const {
query: { hasNextPage, isFetchingNextPage, fetchNextPage },
} = props;
return (
<Button
disabled={!hasNextPage || isFetchingNextPage}
isLoading={isFetchingNextPage}
label={hasNextPage ? 'Load more' : 'Nothing more to load'}
variant="tertiary"
onClick={() => {
fetchNextPage();
}}
/>
);
}

@ -2,40 +2,19 @@ import {
AdjustmentsHorizontalIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui';
import { Button, TextInput } from '@tih/ui';
export type SortOption<Value> = {
label: string;
value: Value;
};
type SortOrderProps<SortOrder> = {
onSortOrderChange?: (sortValue: SortOrder) => void;
sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
sortOrderValue: SortOrder;
};
import type { SortOptionsSelectProps } from './SortOptionsSelect';
import SortOptionsSelect from './SortOptionsSelect';
type SortTypeProps<SortType> = {
onSortTypeChange?: (sortType: SortType) => void;
sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
sortTypeValue: SortType;
export type QuestionSearchBarProps = SortOptionsSelectProps & {
onFilterOptionsToggle: () => void;
};
export type QuestionSearchBarProps<SortType, SortOrder> =
SortOrderProps<SortOrder> &
SortTypeProps<SortType> & {
onFilterOptionsToggle: () => void;
};
export default function QuestionSearchBar<SortType, SortOrder>({
onSortOrderChange,
sortOrderOptions,
sortOrderValue,
onSortTypeChange,
sortTypeOptions,
sortTypeValue,
export default function QuestionSearchBar({
onFilterOptionsToggle,
}: QuestionSearchBarProps<SortType, SortOrder>) {
...sortOptionsSelectProps
}: QuestionSearchBarProps) {
return (
<div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1 ">
@ -48,38 +27,7 @@ export default function QuestionSearchBar<SortType, SortOrder>({
/>
</div>
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2">
<Select
display="inline"
label="Sort by"
options={sortTypeOptions}
value={sortTypeValue}
onChange={(value) => {
const chosenOption = sortTypeOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortTypeChange?.(chosenOption.value);
}
}}
/>
</div>
<div className="flex items-center gap-2">
<Select
display="inline"
label="Order by"
options={sortOrderOptions}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortOrderChange?.(chosenOption.value);
}
}}
/>
</div>
<SortOptionsSelect {...sortOptionsSelectProps} />
<div className="lg:hidden">
<Button
addonPosition="start"

@ -0,0 +1,69 @@
import { Select } from '~/../../../packages/ui/dist';
import { SORT_ORDERS, SORT_TYPES } from '~/utils/questions/constants';
import type { SortOrder, SortType } from '~/types/questions.d';
export type SortOption<Value> = {
label: string;
value: Value;
};
const sortTypeOptions = SORT_TYPES;
const sortOrderOptions = SORT_ORDERS;
type SortOrderProps<Order> = {
onSortOrderChange?: (sortValue: Order) => void;
sortOrderValue: Order;
};
type SortTypeProps<Type> = {
onSortTypeChange?: (sortType: Type) => void;
sortTypeValue: Type;
};
export type SortOptionsSelectProps = SortOrderProps<SortOrder> &
SortTypeProps<SortType>;
export default function SortOptionsSelect({
onSortOrderChange,
sortOrderValue,
onSortTypeChange,
sortTypeValue,
}: SortOptionsSelectProps) {
return (
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2">
<Select
display="inline"
label="Sort by"
options={sortTypeOptions}
value={sortTypeValue}
onChange={(value) => {
const chosenOption = sortTypeOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortTypeChange?.(chosenOption.value);
}
}}
/>
</div>
<div className="flex items-center gap-2">
<Select
display="inline"
label="Order by"
options={sortOrderOptions}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortOrderChange?.(chosenOption.value);
}
}}
/>
</div>
</div>
);
}

@ -1,5 +1,5 @@
import clsx from 'clsx';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
ChatBubbleBottomCenterTextIcon,
CheckIcon,
@ -18,6 +18,8 @@ import QuestionAggregateBadge from '../../QuestionAggregateBadge';
import QuestionTypeBadge from '../../QuestionTypeBadge';
import VotingButtons from '../../VotingButtons';
import type { CountryInfo } from '~/types/questions';
type UpvoteProps =
| {
showVoteButtons: true;
@ -51,13 +53,13 @@ type AnswerStatisticsProps =
type AggregateStatisticsProps =
| {
companies: Record<string, number>;
locations: Record<string, number>;
countries: Record<string, CountryInfo>;
roles: Record<string, number>;
showAggregateStatistics: true;
}
| {
companies?: never;
locations?: never;
countries?: never;
roles?: never;
showAggregateStatistics?: false;
};
@ -136,7 +138,7 @@ export default function BaseQuestionCard({
upvoteCount,
timestamp,
roles,
locations,
countries,
showHover,
onReceivedSubmit,
showDeleteButton,
@ -147,6 +149,22 @@ export default function BaseQuestionCard({
const [showReceivedForm, setShowReceivedForm] = useState(false);
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
const locations = useMemo(() => {
if (countries === undefined) {
return undefined;
}
const countryCount: Record<string, number> = {};
// Decompose countries
for (const country of Object.keys(countries)) {
const { total } = countries[country];
countryCount[country] = total;
}
return countryCount;
}, [countries]);
const cardContent = (
<>
{showVoteButtons && (
@ -168,7 +186,7 @@ export default function BaseQuestionCard({
variant="primary"
/>
<QuestionAggregateBadge
statistics={locations}
statistics={locations!}
variant="success"
/>
<QuestionAggregateBadge statistics={roles} variant="danger" />

@ -1,9 +1,10 @@
import { startOfMonth } from 'date-fns';
import { Controller, useForm } from 'react-hook-form';
import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import {
useFormRegister,
useSelectRegister,
@ -15,14 +16,16 @@ import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker';
import type { Location } from '~/types/questions';
export type ContributeQuestionData = {
company: string;
date: Date;
location: string;
location: Location & TypeaheadOption;
position: string;
questionContent: string;
questionType: QuestionsQuestionType;
role: string;
role: TypeaheadOption;
};
export type ContributeQuestionFormProps = {
@ -79,15 +82,12 @@ export default function ContributeQuestionForm({
name="location"
render={({ field }) => (
<LocationTypeahead
{...field}
required={true}
onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
field.onChange(option.value);
field.onChange(option);
}}
{...field}
value={LOCATIONS.find(
(location) => location.value === field.value,
)}
/>
)}
/>
@ -117,8 +117,9 @@ export default function ContributeQuestionForm({
<Controller
control={control}
name="company"
render={({ field }) => (
render={({ field: { value: _, ...field } }) => (
<CompanyTypeahead
{...field}
required={true}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ id }) => {
@ -134,13 +135,12 @@ export default function ContributeQuestionForm({
name="role"
render={({ field }) => (
<RoleTypeahead
{...field}
required={true}
onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
field.onChange(option.value);
field.onChange(option);
}}
{...field}
value={ROLES.find((role) => role.value === field.value)}
/>
)}
/>

@ -9,11 +9,15 @@ import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Location } from '~/types/questions';
export type CreateQuestionEncounterData = {
cityId?: string;
company: string;
location: string;
countryId: string;
role: string;
seenAt: Date;
stateId?: string;
};
export type CreateQuestionEncounterFormProps = {
@ -28,7 +32,9 @@ export default function CreateQuestionEncounterForm({
const [step, setStep] = useState(0);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<Location | null>(
null,
);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(
startOfMonth(new Date()),
@ -61,10 +67,10 @@ export default function CreateQuestionEncounterForm({
placeholder="Other location"
suggestedCount={3}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ value: location }) => {
onSelect={(location) => {
setSelectedLocation(location);
}}
onSuggestionClick={({ value: location }) => {
onSuggestionClick={(location) => {
setSelectedLocation(location);
setStep(step + 1);
}}
@ -130,11 +136,14 @@ export default function CreateQuestionEncounterForm({
selectedRole &&
selectedDate
) {
const { cityId, stateId, countryId } = selectedLocation;
onSubmit({
cityId,
company: selectedCompany,
location: selectedLocation,
countryId,
role: selectedRole,
seenAt: selectedDate,
stateId,
});
}
}}

@ -8,13 +8,16 @@ import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = RequireAllOrNone<{
clearOnSelect?: boolean;
filterOption: (option: TypeaheadOption) => boolean;
onSuggestionClick: (option: TypeaheadOption) => void;
suggestedCount: number;
}> &
TypeaheadProps;
export type ExpandedTypeaheadProps = Omit<TypeaheadProps, 'onSelect'> &
RequireAllOrNone<{
clearOnSelect?: boolean;
filterOption: (option: TypeaheadOption) => boolean;
onSuggestionClick: (option: TypeaheadOption) => void;
suggestedCount: number;
}> & {
onChange?: unknown; // Workaround: This prop is here just to absorb the onChange returned react-hook-form
onSelect: (option: TypeaheadOption) => void;
};
export default function ExpandedTypeahead({
suggestedCount = 0,
@ -23,6 +26,7 @@ export default function ExpandedTypeahead({
clearOnSelect = false,
options,
onSelect,
onChange: _,
...typeaheadProps
}: ExpandedTypeaheadProps) {
const [key, setKey] = useState(0);
@ -55,7 +59,8 @@ export default function ExpandedTypeahead({
if (clearOnSelect) {
setKey((key + 1) % 2);
}
onSelect(option);
// TODO: Remove onSelect null coercion once onSelect prop is refactored
onSelect(option!);
}}
/>
</div>

@ -1,21 +1,71 @@
import { LOCATIONS } from '~/utils/questions/constants';
import { useMemo, useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
import type { Location } from '~/types/questions';
export type LocationTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
'label' | 'onQueryChange' | 'onSelect' | 'onSuggestionClick' | 'options'
> & {
onSelect: (option: Location & TypeaheadOption) => void;
onSuggestionClick?: (option: Location) => void;
};
export default function LocationTypeahead({
onSelect,
onSuggestionClick,
...restProps
}: LocationTypeaheadProps) {
const [query, setQuery] = useState('');
const { data: locations } = trpc.useQuery([
'locations.cities.list',
{
name: query,
},
]);
const locationOptions = useMemo(() => {
return (
locations?.map(({ id, name, state }) => ({
cityId: id,
countryId: state.country.id,
id,
label: `${name}, ${state.name}, ${state.country.name}`,
stateId: state.id,
value: id,
})) ?? []
);
}, [locations]);
export default function LocationTypeahead(props: LocationTypeaheadProps) {
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
{...({
onSuggestionClick: onSuggestionClick
? (option: TypeaheadOption) => {
const location = locationOptions.find(
(locationOption) => locationOption.id === option.id,
)!;
onSuggestionClick({
...location,
...option,
});
}
: undefined,
...restProps,
} as ExpandedTypeaheadProps)}
label="Location"
options={LOCATIONS}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
options={locationOptions}
onQueryChange={setQuery}
onSelect={({ id }: TypeaheadOption) => {
const location = locationOptions.find((option) => option.id === id)!;
onSelect(location);
}}
/>
);
}

@ -1,3 +1,5 @@
import { useState } from 'react';
import { JobTitleLabels } from '~/components/shared/JobTitles';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
@ -17,13 +19,16 @@ const ROLES: FilterChoices = Object.entries(JobTitleLabels).map(
}),
);
export default function RoleTypeahead(props: RoleTypeaheadProps) {
const [query, setQuery] = useState('');
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Role"
options={ROLES}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
options={ROLES.filter((option) =>
option.label.toLowerCase().includes(query.toLowerCase()),
)}
onQueryChange={setQuery}
/>
);
}

@ -1,17 +1,22 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Select, TextArea } from '@tih/ui';
import { Button, TextArea } from '@tih/ui';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import PaginationLoadMoreButton from '~/components/questions/PaginationLoadMoreButton';
import SortOptionsSelect from '~/components/questions/SortOptionsSelect';
import { APP_TITLE } from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
import { SortOrder, SortType } from '~/types/questions.d';
export type AnswerCommentData = {
commentContent: string;
};
@ -19,6 +24,13 @@ export type AnswerCommentData = {
export default function QuestionPage() {
const router = useRouter();
const [commentSortOrder, setCommentSortOrder] = useState<SortOrder>(
SortOrder.DESC,
);
const [commentSortType, setCommentSortType] = useState<SortType>(
SortType.NEW,
);
const {
register: comRegister,
reset: resetComment,
@ -36,10 +48,23 @@ export default function QuestionPage() {
{ answerId: answerId as string },
]);
const { data: comments } = trpc.useQuery([
'questions.answers.comments.getAnswerComments',
{ answerId: answerId as string },
]);
const answerCommentInfiniteQuery = trpc.useInfiniteQuery(
[
'questions.answers.comments.getAnswerComments',
{
answerId: answerId as string,
limit: 5,
sortOrder: commentSortOrder,
sortType: commentSortType,
},
],
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
},
);
const { data: answerCommentsData } = answerCommentInfiniteQuery;
const { mutate: addComment } = trpc.useMutation(
'questions.answers.comments.user.create',
@ -47,7 +72,11 @@ export default function QuestionPage() {
onSuccess: () => {
utils.invalidateQueries([
'questions.answers.comments.getAnswerComments',
{ answerId: answerId as string },
{
answerId: answerId as string,
sortOrder: SortOrder.DESC,
sortType: SortType.NEW,
},
]);
},
},
@ -108,32 +137,6 @@ export default function QuestionPage() {
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
@ -142,18 +145,35 @@ export default function QuestionPage() {
/>
</div>
</form>
{(comments ?? []).map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={commentSortOrder}
sortTypeValue={commentSortType}
onSortOrderChange={setCommentSortOrder}
onSortTypeChange={setCommentSortType}
/>
</div>
</div>
{/* TODO: Allow to load more pages */}
{(answerCommentsData?.pages ?? []).flatMap(
({ processedQuestionAnswerCommentsData: comments }) =>
comments.map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={answerCommentInfiniteQuery} />
</div>
</div>
</div>
</div>

@ -1,14 +1,16 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui';
import { Button, Collapsible, HorizontalDivider, TextArea } from '@tih/ui';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullQuestionCard from '~/components/questions/card/question/FullQuestionCard';
import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import PaginationLoadMoreButton from '~/components/questions/PaginationLoadMoreButton';
import SortOptionsSelect from '~/components/questions/SortOptionsSelect';
import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
@ -16,6 +18,8 @@ import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregat
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
import { SortOrder, SortType } from '~/types/questions.d';
export type AnswerQuestionData = {
answerContent: string;
};
@ -26,6 +30,19 @@ export type QuestionCommentData = {
export default function QuestionPage() {
const router = useRouter();
const [answerSortOrder, setAnswerSortOrder] = useState<SortOrder>(
SortOrder.DESC,
);
const [answerSortType, setAnswerSortType] = useState<SortType>(SortType.NEW);
const [commentSortOrder, setCommentSortOrder] = useState<SortOrder>(
SortOrder.DESC,
);
const [commentSortType, setCommentSortType] = useState<SortType>(
SortType.NEW,
);
const {
register: ansRegister,
handleSubmit,
@ -64,10 +81,23 @@ export default function QuestionPage() {
const utils = trpc.useContext();
const { data: comments } = trpc.useQuery([
'questions.questions.comments.getQuestionComments',
{ questionId: questionId as string },
]);
const commentInfiniteQuery = trpc.useInfiniteQuery(
[
'questions.questions.comments.getQuestionComments',
{
limit: 5,
questionId: questionId as string,
sortOrder: commentSortOrder,
sortType: commentSortType,
},
],
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
},
);
const { data: commentData } = commentInfiniteQuery;
const { mutate: addComment } = trpc.useMutation(
'questions.questions.comments.user.create',
@ -80,10 +110,23 @@ export default function QuestionPage() {
},
);
const { data: answers } = trpc.useQuery([
'questions.answers.getAnswers',
{ questionId: questionId as string },
]);
const answerInfiniteQuery = trpc.useInfiniteQuery(
[
'questions.answers.getAnswers',
{
limit: 5,
questionId: questionId as string,
sortOrder: answerSortOrder,
sortType: answerSortType,
},
],
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
},
);
const { data: answerData } = answerInfiniteQuery;
const { mutate: addAnswer } = trpc.useMutation(
'questions.answers.user.create',
@ -144,12 +187,12 @@ export default function QuestionPage() {
variant="secondary"
/>
</div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullQuestionCard
{...question}
companies={relabeledAggregatedEncounters?.companyCounts ?? {}}
locations={relabeledAggregatedEncounters?.locationCounts ?? {}}
countries={relabeledAggregatedEncounters?.countryCounts ?? {}}
questionId={question.id}
receivedCount={undefined}
roles={relabeledAggregatedEncounters?.roleCounts ?? {}}
@ -160,78 +203,74 @@ export default function QuestionPage() {
upvoteCount={question.numVotes}
onReceivedSubmit={(data) => {
addEncounter({
cityId: data.cityId,
companyId: data.company,
location: data.location,
countryId: data.countryId,
questionId: questionId as string,
role: data.role,
seenAt: data.seenAt,
stateId: data.stateId,
});
}}
/>
<div className="mx-2">
<Collapsible label={`${(comments ?? []).length} comment(s)`}>
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
<Collapsible label={`${question.numComments} comment(s)`}>
<div className="mt-4 px-4">
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</form>
{/* TODO: Add button to load more */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={commentSortOrder}
sortTypeValue={commentSortType}
onSortOrderChange={setCommentSortOrder}
onSortTypeChange={setCommentSortType}
/>
</div>
</div>
{(commentData?.pages ?? []).flatMap(
({ processedQuestionCommentsData: comments }) =>
comments.map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={commentInfiniteQuery} />
</div>
</form>
{(comments ?? []).map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
</div>
</Collapsible>
</div>
<HorizontalDivider />
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
<TextArea
{...answerRegister('answerContent', {
@ -244,34 +283,6 @@ export default function QuestionPage() {
rows={5}
/>
<div className="mt-3 mb-1 flex justify-between">
<div className="flex items-baseline justify-start gap-2">
<p>{(answers ?? []).length} answers</p>
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
</div>
<Button
disabled={!isDirty || !isValid}
label="Contribute"
@ -280,21 +291,37 @@ export default function QuestionPage() {
/>
</div>
</form>
{(answers ?? []).map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
))}
<div className="flex items-center justify-between gap-2">
<p className="text-xl">{question.numAnswers} answers</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={answerSortOrder}
sortTypeValue={answerSortType}
onSortOrderChange={setAnswerSortOrder}
onSortTypeChange={setAnswerSortType}
/>
</div>
</div>
{/* TODO: Add button to load more */}
{(answerData?.pages ?? []).flatMap(
({ processedAnswersData: answers }) =>
answers.map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={answerInfiniteQuery} />
</div>
</div>
</div>

@ -5,11 +5,13 @@ import { useEffect, useMemo, useState } from 'react';
import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import FilterSection from '~/components/questions/filter/FilterSection';
import PaginationLoadMoreButton from '~/components/questions/PaginationLoadMoreButton';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
import LocationTypeahead from '~/components/questions/typeahead/LocationTypeahead';
@ -17,8 +19,6 @@ import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
import { JobTitleLabels } from '~/components/shared/JobTitles';
import type { QuestionAge } from '~/utils/questions/constants';
import { SORT_TYPES } from '~/utils/questions/constants';
import { SORT_ORDERS } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants';
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
@ -29,14 +29,29 @@ import {
} from '~/utils/questions/useSearchParam';
import { trpc } from '~/utils/trpc';
import type { Location } from '~/types/questions.d';
import { SortType } from '~/types/questions.d';
import { SortOrder } from '~/types/questions.d';
function locationToSlug(value: Location & TypeaheadOption): string {
return [
value.countryId,
value.stateId,
value.cityId,
value.id,
value.label,
value.value,
].join('-');
}
export default function QuestionsBrowsePage() {
const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchParam('companies');
const [
selectedCompanySlugs,
setSelectedCompanySlugs,
areCompaniesInitialized,
] = useSearchParam('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
@ -70,7 +85,13 @@ export default function QuestionsBrowsePage() {
const [selectedRoles, setSelectedRoles, areRolesInitialized] =
useSearchParam('roles');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchParam('locations');
useSearchParam<Location & TypeaheadOption>('locations', {
paramToString: locationToSlug,
stringToParam: (param) => {
const [countryId, stateId, cityId, id, label, value] = param.split('-');
return { cityId, countryId, id, label, stateId, value };
},
});
const [sortOrder, setSortOrder, isSortOrderInitialized] =
useSearchParamSingle<SortOrder>('sortOrder', {
@ -122,13 +143,13 @@ export default function QuestionsBrowsePage() {
const hasFilters = useMemo(
() =>
selectedCompanies.length > 0 ||
selectedCompanySlugs.length > 0 ||
selectedQuestionTypes.length > 0 ||
selectedQuestionAge !== 'all' ||
selectedRoles.length > 0 ||
selectedLocations.length > 0,
[
selectedCompanies,
selectedCompanySlugs,
selectedQuestionTypes,
selectedQuestionAge,
selectedRoles,
@ -147,24 +168,24 @@ export default function QuestionsBrowsePage() {
: undefined;
}, [selectedQuestionAge]);
const {
data: questionsQueryData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = trpc.useInfiniteQuery(
const questionsInfiniteQuery = trpc.useInfiniteQuery(
[
'questions.questions.getQuestionsByFilter',
{
companyNames: selectedCompanies,
// TODO: Enable filtering by countryIds and stateIds
cityIds: selectedLocations
.map(({ cityId }) => cityId)
.filter((id) => id !== undefined) as Array<string>,
companyIds: selectedCompanySlugs.map((slug) => slug.split('_')[0]),
countryIds: [],
endDate: today,
limit: 10,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
sortOrder,
sortType,
startDate,
stateIds: [],
},
],
{
@ -173,6 +194,8 @@ export default function QuestionsBrowsePage() {
},
);
const { data: questionsQueryData } = questionsInfiniteQuery;
const questionCount = useMemo(() => {
if (!questionsQueryData) {
return undefined;
@ -239,8 +262,8 @@ export default function QuestionsBrowsePage() {
Router.replace({
pathname,
query: {
companies: selectedCompanies,
locations: selectedLocations,
companies: selectedCompanySlugs,
locations: selectedLocations.map(locationToSlug),
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
@ -255,7 +278,7 @@ export default function QuestionsBrowsePage() {
areSearchOptionsInitialized,
loaded,
pathname,
selectedCompanies,
selectedCompanySlugs,
selectedRoles,
selectedLocations,
selectedQuestionAge,
@ -265,13 +288,16 @@ export default function QuestionsBrowsePage() {
]);
const selectedCompanyOptions = useMemo(() => {
return selectedCompanies.map((company) => ({
checked: true,
id: company,
label: company,
value: company,
}));
}, [selectedCompanies]);
return selectedCompanySlugs.map((company) => {
const [id, label] = company.split('_');
return {
checked: true,
id,
label,
value: id,
};
});
}, [selectedCompanySlugs]);
const selectedRoleOptions = useMemo(() => {
return selectedRoles.map((role) => ({
@ -285,9 +311,7 @@ export default function QuestionsBrowsePage() {
const selectedLocationOptions = useMemo(() => {
return selectedLocations.map((location) => ({
checked: true,
id: location,
label: location,
value: location,
...location,
}));
}, [selectedLocations]);
@ -305,7 +329,7 @@ export default function QuestionsBrowsePage() {
label="Clear filters"
variant="tertiary"
onClick={() => {
setSelectedCompanies([]);
setSelectedCompanySlugs([]);
setSelectedQuestionTypes([]);
setSelectedQuestionAge('all');
setSelectedRoles([]);
@ -320,8 +344,8 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedCompanies.some((company) => {
return company === option.value;
return !selectedCompanySlugs.some((companySlug) => {
return companySlug === `${option.id}_${option.label}`;
});
}}
isLabelHidden={true}
@ -337,10 +361,15 @@ export default function QuestionsBrowsePage() {
)}
onOptionChange={(option) => {
if (option.checked) {
setSelectedCompanies([...selectedCompanies, option.label]);
setSelectedCompanySlugs([
...selectedCompanySlugs,
`${option.id}_${option.label}`,
]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== option.label),
setSelectedCompanySlugs(
selectedCompanySlugs.filter(
(companySlug) => companySlug !== `${option.id}_${option.label}`,
),
);
}
}}
@ -348,7 +377,10 @@ export default function QuestionsBrowsePage() {
<FilterSection
label="Roles"
options={selectedRoleOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
renderInput={({
onOptionChange,
field: { ref: _, onChange: __, ...field },
}) => (
<RoleTypeahead
{...field}
clearOnSelect={true}
@ -406,13 +438,16 @@ export default function QuestionsBrowsePage() {
<FilterSection
label="Locations"
options={selectedLocationOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
renderInput={({
onOptionChange,
field: { ref: _, onChange: __, ...field },
}) => (
<LocationTypeahead
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedLocations.some((location) => {
return location === option.value;
return location.id === option.id;
});
}}
isLabelHidden={true}
@ -428,10 +463,14 @@ export default function QuestionsBrowsePage() {
)}
onOptionChange={(option) => {
if (option.checked) {
setSelectedLocations([...selectedLocations, option.value]);
// TODO: Fix type inference, then remove the `as` cast.
setSelectedLocations([
...selectedLocations,
option as unknown as Location & TypeaheadOption,
]);
} else {
setSelectedLocations(
selectedLocations.filter((role) => role !== option.value),
selectedLocations.filter((location) => location.id !== option.id),
);
}
}}
@ -450,21 +489,22 @@ export default function QuestionsBrowsePage() {
<div className="m-4 flex max-w-3xl flex-1 flex-col items-stretch justify-start gap-8">
<ContributeQuestionCard
onSubmit={(data) => {
const { cityId, countryId, stateId } = data.location;
createQuestion({
cityId,
companyId: data.company,
content: data.questionContent,
location: data.location,
countryId,
questionType: data.questionType,
role: data.role,
role: data.role.value,
seenAt: data.date,
stateId,
});
}}
/>
<div className="sticky top-0 border-b border-slate-300 bg-slate-50 py-4">
<QuestionSearchBar
sortOrderOptions={SORT_ORDERS}
sortOrderValue={sortOrder}
sortTypeOptions={SORT_TYPES}
sortTypeValue={sortType}
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
@ -477,7 +517,7 @@ export default function QuestionsBrowsePage() {
{(questionsQueryData?.pages ?? []).flatMap(
({ data: questions }) =>
questions.map((question) => {
const { companyCounts, locationCounts, roleCounts } =
const { companyCounts, countryCounts, roleCounts } =
relabelQuestionAggregates(
question.aggregatedQuestionEncounters,
);
@ -488,10 +528,10 @@ export default function QuestionsBrowsePage() {
answerCount={question.numAnswers}
companies={companyCounts}
content={question.content}
countries={countryCounts}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={locationCounts}
questionId={question.id}
receivedCount={question.receivedCount}
roles={roleCounts}
@ -508,15 +548,7 @@ export default function QuestionsBrowsePage() {
);
}),
)}
<Button
disabled={!hasNextPage || isFetchingNextPage}
isLoading={isFetchingNextPage}
label={hasNextPage ? 'Load more' : 'Nothing more to load'}
variant="tertiary"
onClick={() => {
fetchNextPage();
}}
/>
<PaginationLoadMoreButton query={questionsInfiniteQuery} />
{questionCount === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" />

@ -174,7 +174,7 @@ export default function ListPage() {
<div className="flex flex-col gap-4 pb-4">
{lists[selectedListIndex].questionEntries.map(
({ question, id: entryId }) => {
const { companyCounts, locationCounts, roleCounts } =
const { companyCounts, countryCounts, roleCounts } =
relabelQuestionAggregates(
question.aggregatedQuestionEncounters,
);
@ -184,10 +184,10 @@ export default function ListPage() {
key={question.id}
companies={companyCounts}
content={question.content}
countries={countryCounts}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={locationCounts}
questionId={question.id}
receivedCount={question.receivedCount}
roles={roleCounts}

@ -19,9 +19,11 @@ export const locationsRouter = createRouter()
select: {
country: {
select: {
id: true,
name: true,
},
},
id: true,
name: true,
},
},

@ -4,16 +4,43 @@ import { Vote } from '@prisma/client';
import { createRouter } from '../context';
import type { AnswerComment } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsAnswerCommentRouter = createRouter().query(
'getAnswerComments',
{
input: z.object({
answerId: z.string(),
cursor: z.string().nullish(),
limit: z.number().min(1).default(50),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
}),
async resolve({ ctx, input }) {
const { answerId, cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
updatedAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const questionAnswerCommentsData =
await ctx.prisma.questionsAnswerComment.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: {
user: {
select: {
@ -23,14 +50,13 @@ export const questionsAnswerCommentRouter = createRouter().query(
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
answerId: input.answerId,
answerId,
},
});
return questionAnswerCommentsData.map((data) => {
const processedQuestionAnswerCommentsData = questionAnswerCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
@ -59,6 +85,22 @@ export const questionsAnswerCommentRouter = createRouter().query(
};
return answerComment;
});
let nextCursor: typeof cursor | undefined = undefined;
if (questionAnswerCommentsData.length > input.limit) {
const nextItem = questionAnswerCommentsData.pop()!;
processedQuestionAnswerCommentsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
nextCursor = nextIdCursor;
}
return {
nextCursor,
processedQuestionAnswerCommentsData,
}
},
},
);

@ -39,7 +39,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter()
},
});
if (answerCommentToUpdate?.id !== userId) {
if (answerCommentToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -71,7 +71,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter()
},
});
if (answerCommentToDelete?.id !== userId) {
if (answerCommentToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -100,130 +100,248 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter()
});
},
})
.mutation('createVote', {
.mutation('setUpVote', {
input: z.object({
answerCommentId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerCommentId } = input;
const { answerCommentId, vote } = input;
return await ctx.prisma.$transaction(async (tx) => {
const answerCommentToUpdate =
await tx.questionsAnswerComment.findUnique({
where: {
id: answerCommentId,
},
});
const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
if (answerCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer Comment do not exist.',
});
}
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.create({
data: {
answerCommentId,
userId,
vote,
},
}),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
const vote = await tx.questionsAnswerCommentVote.findUnique({
where: {
id: answerCommentId,
answerCommentId_userId: { answerCommentId, userId },
},
}),
]);
});
if (vote === null) {
const createdVote = await tx.questionsAnswerCommentVote.create({
data: {
answerCommentId,
userId,
vote: Vote.UPVOTE,
},
});
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: 1,
},
},
where: {
id: answerCommentId,
},
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.UPVOTE) {
return vote;
}
if (vote.vote === Vote.DOWNVOTE) {
const updatedVote = await tx.questionsAnswerCommentVote.update({
data: {
answerCommentId,
userId,
vote: Vote.UPVOTE,
},
where: {
id: vote.id,
},
});
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: 2,
},
},
where: {
id: answerCommentId,
},
});
return answerCommentVote;
return updatedVote;
}
});
},
})
.mutation('updateVote', {
.mutation('setDownVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
answerCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const { answerCommentId } = input;
return await ctx.prisma.$transaction(async (tx) => {
const answerCommentToUpdate =
await tx.questionsAnswerComment.findUnique({
where: {
id: answerCommentId,
},
});
if (answerCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer Comment do not exist.',
});
}
const voteToUpdate =
await ctx.prisma.questionsAnswerCommentVote.findUnique({
const vote = await tx.questionsAnswerCommentVote.findUnique({
where: {
id: input.id,
answerCommentId_userId: { answerCommentId, userId },
},
});
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote === null) {
const createdVote = await tx.questionsAnswerCommentVote.create({
data: {
answerCommentId,
userId,
vote: Vote.DOWNVOTE,
},
});
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: -1,
},
},
where: {
id: answerCommentId,
},
});
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.update({
data: {
vote,
},
where: {
id,
},
}),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.DOWNVOTE) {
return vote;
}
if (vote.vote === Vote.UPVOTE) {
const updatedVote = await tx.questionsAnswerCommentVote.update({
data: {
answerCommentId,
userId,
vote: Vote.DOWNVOTE,
},
},
where: {
id: voteToUpdate.answerCommentId,
},
}),
]);
where: {
id: vote.id,
},
});
return answerCommentVote;
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: -2,
},
},
where: {
id: answerCommentId,
},
});
return updatedVote;
}
});
},
})
.mutation('deleteVote', {
.mutation('setNoVote', {
input: z.object({
id: z.string(),
answerCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerCommentId } = input;
return await ctx.prisma.$transaction(async (tx) => {
const answerCommentToUpdate =
await tx.questionsAnswerComment.findUnique({
where: {
id: answerCommentId,
},
});
if (answerCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer Comment do not exist.',
});
}
const voteToDelete =
await ctx.prisma.questionsAnswerCommentVote.findUnique({
const voteToDelete = await tx.questionsAnswerCommentVote.findUnique({
where: {
id: input.id,
answerCommentId_userId: { answerCommentId, userId },
},
});
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (voteToDelete === null) {
return null;
}
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 incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.delete({
await tx.questionsAnswerCommentVote.delete({
where: {
id: input.id,
id: voteToDelete.id,
},
}),
ctx.prisma.questionsAnswerComment.update({
});
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.answerCommentId,
id: answerCommentId,
},
}),
]);
return answerCommentVote;
});
return voteToDelete;
});
},
});

@ -5,16 +5,42 @@ import { TRPCError } from '@trpc/server';
import { createRouter } from '../context';
import type { Answer } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsAnswerRouter = createRouter()
.query('getAnswers', {
input: z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).default(50),
questionId: z.string(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
}),
async resolve({ ctx, input }) {
const { questionId } = input;
const { questionId, cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
updatedAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const answersData = await ctx.prisma.questionsAnswer.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: {
_count: {
select: {
@ -29,14 +55,14 @@ export const questionsAnswerRouter = createRouter()
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
questionId,
},
});
return answersData.map((data) => {
const processedAnswersData = answersData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
@ -65,6 +91,22 @@ export const questionsAnswerRouter = createRouter()
};
return answer;
});
let nextCursor: typeof cursor | undefined = undefined;
if (answersData.length > input.limit) {
const nextItem = answersData.pop()!;
processedAnswersData.pop();
const nextIdCursor: string | undefined = nextItem.id;
nextCursor = nextIdCursor;
}
return {
nextCursor,
processedAnswersData,
}
},
})
.query('getAnswerById', {

@ -39,7 +39,7 @@ export const questionsAnswerUserRouter = createProtectedRouter()
},
});
if (answerToUpdate?.id !== userId) {
if (answerToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -69,7 +69,7 @@ export const questionsAnswerUserRouter = createProtectedRouter()
},
});
if (answerToDelete?.id !== userId) {
if (answerToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -98,127 +98,245 @@ export const questionsAnswerUserRouter = createProtectedRouter()
});
},
})
.mutation('createVote', {
.mutation('setUpVote', {
input: z.object({
answerId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerId } = input;
const { answerId, vote } = input;
return await ctx.prisma.$transaction(async (tx) => {
const answerToUpdate = await tx.questionsAnswer.findUnique({
where: {
id: answerId,
},
});
const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
if (answerToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer do not exist.',
});
}
const [answerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.create({
data: {
answerId,
userId,
vote,
},
}),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
const vote = await tx.questionsAnswerVote.findUnique({
where: {
id: answerId,
answerId_userId: { answerId, userId },
},
}),
]);
return answerVote;
});
if (vote === null) {
const createdVote = await tx.questionsAnswerVote.create({
data: {
answerId,
userId,
vote: Vote.UPVOTE,
},
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: 1,
},
},
where: {
id: answerId,
},
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.UPVOTE) {
return vote;
}
if (vote.vote === Vote.DOWNVOTE) {
const updatedVote = await tx.questionsAnswerVote.update({
data: {
answerId,
userId,
vote: Vote.UPVOTE,
},
where: {
id: vote.id,
},
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: 2,
},
},
where: {
id: answerId,
},
});
return updatedVote;
}
});
},
})
.mutation('updateVote', {
.mutation('setDownVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const voteToUpdate = await ctx.prisma.questionsAnswerVote.findUnique({
where: {
id: input.id,
},
});
const { answerId } = input;
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
return await ctx.prisma.$transaction(async (tx) => {
const answerToUpdate = await tx.questionsAnswer.findUnique({
where: {
id: answerId,
},
});
}
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
if (answerToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer do not exist.',
});
}
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.update({
data: {
vote,
},
const vote = await tx.questionsAnswerVote.findUnique({
where: {
id,
answerId_userId: { answerId, userId },
},
}),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
});
if (vote === null) {
const createdVote = await tx.questionsAnswerVote.create({
data: {
answerId,
userId,
vote: Vote.DOWNVOTE,
},
},
where: {
id: voteToUpdate.answerId,
},
}),
]);
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: -1,
},
},
where: {
id: answerId,
},
});
return createdVote;
}
return questionsAnswerVote;
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.DOWNVOTE) {
return vote;
}
if (vote.vote === Vote.UPVOTE) {
const updatedVote = await tx.questionsAnswerVote.update({
data: {
answerId,
userId,
vote: Vote.DOWNVOTE,
},
where: {
id: vote.id,
},
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: -2,
},
},
where: {
id: answerId,
},
});
return updatedVote;
}
});
},
})
.mutation('deleteVote', {
.mutation('setNoVote', {
input: z.object({
id: z.string(),
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerId } = input;
const voteToDelete = await ctx.prisma.questionsAnswerVote.findUnique({
where: {
id: input.id,
},
});
return await ctx.prisma.$transaction(async (tx) => {
const answerToUpdate = await tx.questionsAnswer.findUnique({
where: {
id: answerId,
},
});
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
if (answerToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer do not exist.',
});
}
const voteToDelete = await tx.questionsAnswerVote.findUnique({
where: {
answerId_userId: { answerId, userId },
},
});
}
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
if (voteToDelete === null) {
return null;
}
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.delete({
if (voteToDelete!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1;
await tx.questionsAnswerVote.delete({
where: {
id: input.id,
id: voteToDelete.id,
},
}),
ctx.prisma.questionsAnswer.update({
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.answerId,
id: answerId,
},
}),
]);
return questionsAnswerVote;
});
return voteToDelete;
});
},
});

@ -25,10 +25,12 @@ export const questionsListRouter = createProtectedRouter()
},
encounters: {
select: {
city: true,
company: true,
location: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
@ -83,10 +85,12 @@ export const questionsListRouter = createProtectedRouter()
},
encounters: {
select: {
city: true,
company: true,
location: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {

@ -4,17 +4,43 @@ import { Vote } from '@prisma/client';
import { createRouter } from '../context';
import type { QuestionComment } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionCommentRouter = createRouter().query(
'getQuestionComments',
{
input: z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).default(50),
questionId: z.string(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
}),
async resolve({ ctx, input }) {
const { questionId } = input;
const { questionId, cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
updatedAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const questionCommentsData =
await ctx.prisma.questionsQuestionComment.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: {
user: {
select: {
@ -24,14 +50,13 @@ export const questionsQuestionCommentRouter = createRouter().query(
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
questionId,
},
});
return questionCommentsData.map((data) => {
const processedQuestionCommentsData = questionCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
@ -59,6 +84,22 @@ export const questionsQuestionCommentRouter = createRouter().query(
};
return questionComment;
});
let nextCursor: typeof cursor | undefined = undefined;
if (questionCommentsData.length > input.limit) {
const nextItem = questionCommentsData.pop()!;
processedQuestionCommentsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
nextCursor = nextIdCursor;
}
return {
nextCursor,
processedQuestionCommentsData,
}
},
},
);

@ -41,7 +41,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter()
},
});
if (questionCommentToUpdate?.id !== userId) {
if (questionCommentToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -72,7 +72,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter()
},
});
if (questionCommentToDelete?.id !== userId) {
if (questionCommentToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -101,128 +101,251 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter()
});
},
})
.mutation('createVote', {
.mutation('setUpVote', {
input: z.object({
questionCommentId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionCommentId, vote } = input;
const incrementValue: number = vote === Vote.UPVOTE ? 1 : -1;
const { questionCommentId } = input;
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.create({
data: {
questionCommentId,
userId,
vote,
},
}),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
return await ctx.prisma.$transaction(async (tx) => {
const questionCommentToUpdate =
await tx.questionsQuestionComment.findUnique({
where: {
id: questionCommentId,
},
},
});
if (questionCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question Comment do not exist.',
});
}
const vote = await tx.questionsQuestionCommentVote.findUnique({
where: {
id: questionCommentId,
questionCommentId_userId: { questionCommentId, userId },
},
}),
]);
return questionCommentVote;
});
if (vote === null) {
const createdVote = await tx.questionsQuestionCommentVote.create({
data: {
questionCommentId,
userId,
vote: Vote.UPVOTE,
},
});
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: 1,
},
},
where: {
id: questionCommentId,
},
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.UPVOTE) {
return vote;
}
if (vote.vote === Vote.DOWNVOTE) {
const updatedVote = await tx.questionsQuestionCommentVote.update({
data: {
questionCommentId,
userId,
vote: Vote.UPVOTE,
},
where: {
id: vote.id,
},
});
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: 2,
},
},
where: {
id: questionCommentId,
},
});
return updatedVote;
}
});
},
})
.mutation('updateVote', {
.mutation('setDownVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
questionCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const { questionCommentId } = input;
const voteToUpdate =
await ctx.prisma.questionsQuestionCommentVote.findUnique({
return await ctx.prisma.$transaction(async (tx) => {
const questionCommentToUpdate =
await tx.questionsQuestionComment.findUnique({
where: {
id: questionCommentId,
},
});
if (questionCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question Comment do not exist.',
});
}
const vote = await tx.questionsQuestionCommentVote.findUnique({
where: {
id: input.id,
questionCommentId_userId: { questionCommentId, userId },
},
});
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote === null) {
const createdVote = await tx.questionsQuestionCommentVote.create({
data: {
questionCommentId,
userId,
vote: Vote.DOWNVOTE,
},
});
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: -1,
},
},
where: {
id: questionCommentId,
},
});
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.update({
data: {
vote,
},
where: {
id,
},
}),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.DOWNVOTE) {
return vote;
}
if (vote.vote === Vote.UPVOTE) {
tx.questionsQuestionCommentVote.delete({
where: {
id: vote.id,
},
},
where: {
id: voteToUpdate.questionCommentId,
},
}),
]);
});
const createdVote = await tx.questionsQuestionCommentVote.create({
data: {
questionCommentId,
userId,
vote: Vote.DOWNVOTE,
},
});
return questionCommentVote;
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: -2,
},
},
where: {
id: questionCommentId,
},
});
return createdVote;
}
});
},
})
.mutation('deleteVote', {
.mutation('setNoVote', {
input: z.object({
id: z.string(),
questionCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionCommentId } = input;
return await ctx.prisma.$transaction(async (tx) => {
const questionCommentToUpdate =
await tx.questionsQuestionComment.findUnique({
where: {
id: questionCommentId,
},
});
const voteToDelete =
await ctx.prisma.questionsQuestionCommentVote.findUnique({
if (questionCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question Comment do not exist.',
});
}
const voteToDelete = await tx.questionsQuestionCommentVote.findUnique({
where: {
id: input.id,
questionCommentId_userId: { questionCommentId, userId },
},
});
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (voteToDelete === null) {
return null;
}
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 incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1;
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.delete({
await tx.questionsQuestionCommentVote.delete({
where: {
id: input.id,
id: voteToDelete.id,
},
}),
ctx.prisma.questionsQuestionComment.update({
});
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.questionCommentId,
id: questionCommentId,
},
}),
]);
return questionCommentVote;
});
return voteToDelete;
});
},
});

@ -1,8 +1,8 @@
import { z } from 'zod';
import { createRouter } from '../context';
import { createAggregatedQuestionEncounter } from '~/utils/questions/server/aggregate-encounters';
import type { AggregatedQuestionEncounter } from '~/types/questions';
import { createRouter } from '../context';
export const questionsQuestionEncounterRouter = createRouter().query(
'getAggregatedEncounters',
@ -14,48 +14,17 @@ export const questionsQuestionEncounterRouter = createRouter().query(
const questionEncountersData =
await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
city: true,
company: true,
country: true,
state: true,
},
where: {
...input,
},
});
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
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;
return createAggregatedQuestionEncounter(questionEncountersData);
},
},
);

@ -1,38 +1,22 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createAggregatedQuestionEncounter } from '~/utils/questions/server/aggregate-encounters';
import { JobTitleLabels } from '~/components/shared/JobTitles';
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({
cityId: z.string().nullish(),
companyId: z.string(),
location: z.string(),
countryId: z.string(),
questionId: z.string(),
role: z.string(),
role: z.nativeEnum(JobTitleLabels),
seenAt: z.date(),
stateId: z.string().nullish(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
@ -94,7 +78,7 @@ export const questionsQuestionEncounterUserRouter = createProtectedRouter()
},
});
if (questionEncounterToUpdate?.id !== userId) {
if (questionEncounterToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -157,7 +141,7 @@ export const questionsQuestionEncounterUserRouter = createProtectedRouter()
},
});
if (questionEncounterToDelete?.id !== userId) {
if (questionEncounterToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',

@ -11,22 +11,18 @@ 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(),
cityIds: z.string().array(),
companyIds: z.string().array(),
countryIds: z.string().array(),
cursor: z.string().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(),
stateIds: z.string().array(),
}),
async resolve({ ctx, input }) {
const { cursor } = input;
@ -51,12 +47,7 @@ export const questionsQuestionRouter = createRouter()
];
const questionsData = await ctx.prisma.questionsQuestion.findMany({
cursor:
cursor !== undefined
? {
id: cursor ? cursor!.idCursor : undefined,
}
: undefined,
cursor: cursor ? { id: cursor } : undefined,
include: {
_count: {
select: {
@ -66,10 +57,12 @@ export const questionsQuestionRouter = createRouter()
},
encounters: {
select: {
city: true,
company: true,
location: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
@ -95,19 +88,39 @@ export const questionsQuestionRouter = createRouter()
gte: input.startDate,
lte: input.endDate,
},
...(input.companyNames.length > 0
...(input.companyIds.length > 0
? {
company: {
name: {
in: input.companyNames,
id: {
in: input.companyIds,
},
},
}
: {}),
...(input.cityIds.length > 0
? {
city: {
id: {
in: input.cityIds,
},
},
}
: {}),
...(input.countryIds.length > 0
? {
country: {
id: {
in: input.countryIds,
},
},
}
: {}),
...(input.locations.length > 0
...(input.stateIds.length > 0
? {
location: {
in: input.locations,
state: {
id: {
in: input.stateIds,
},
},
}
: {}),
@ -134,16 +147,8 @@ export const questionsQuestionRouter = createRouter()
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,
};
nextCursor = nextIdCursor;
}
return {
@ -167,10 +172,12 @@ export const questionsQuestionRouter = createRouter()
},
encounters: {
select: {
city: true,
company: true,
location: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
@ -201,21 +208,23 @@ export const questionsQuestionRouter = createRouter()
async resolve({ ctx, input }) {
const escapeChars = /[()|&:*!]/g;
const query =
input.content
.replace(escapeChars, " ")
.trim()
.split(/\s+/)
.join(" | ");
const query = input.content
.replace(escapeChars, ' ')
.trim()
.split(/\s+/)
.join(' | ');
const relatedQuestionsId : Array<{id:string}> = await ctx.prisma.$queryRaw`
const relatedQuestionsId: Array<{ id: string }> = await ctx.prisma
.$queryRaw`
SELECT id FROM "QuestionsQuestion"
WHERE
to_tsvector("content") @@ to_tsquery('english', ${query})
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC;
`;
const relatedQuestionsIdArray = relatedQuestionsId.map(current => current.id);
const relatedQuestionsIdArray = relatedQuestionsId.map(
(current) => current.id,
);
const relatedQuestionsData = await ctx.prisma.questionsQuestion.findMany({
include: {
@ -227,10 +236,12 @@ export const questionsQuestionRouter = createRouter()
},
encounters: {
select: {
city: true,
company: true,
location: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
@ -241,9 +252,9 @@ export const questionsQuestionRouter = createRouter()
votes: true,
},
where: {
id : {
in : relatedQuestionsIdArray,
}
id: {
in: relatedQuestionsIdArray,
},
},
});
@ -252,5 +263,5 @@ export const questionsQuestionRouter = createRouter()
);
return processedQuestionsData;
}
},
});

@ -7,12 +7,14 @@ import { createProtectedRouter } from '../context';
export const questionsQuestionUserRouter = createProtectedRouter()
.mutation('create', {
input: z.object({
cityId: z.string().nullish(),
companyId: z.string(),
content: z.string(),
location: z.string(),
countryId: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType),
role: z.string(),
seenAt: z.date(),
stateId: z.string().nullish(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
@ -22,14 +24,34 @@ export const questionsQuestionUserRouter = createProtectedRouter()
content: input.content,
encounters: {
create: {
city:
input.cityId !== null
? {
connect: {
id: input.cityId,
},
}
: undefined,
company: {
connect: {
id: input.companyId,
},
},
location: input.location,
country: {
connect: {
id: input.countryId,
},
},
role: input.role,
seenAt: input.seenAt,
state:
input.stateId !== null
? {
connect: {
id: input.stateId,
},
}
: undefined,
user: {
connect: {
id: userId,
@ -59,7 +81,7 @@ export const questionsQuestionUserRouter = createProtectedRouter()
},
});
if (questionToUpdate?.id !== userId) {
if (questionToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -93,7 +115,7 @@ export const questionsQuestionUserRouter = createProtectedRouter()
},
});
if (questionToDelete?.id !== userId) {
if (questionToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -123,126 +145,245 @@ export const questionsQuestionUserRouter = createProtectedRouter()
});
},
})
.mutation('createVote', {
.mutation('setUpVote', {
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 { questionId } = input;
const [questionVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionVote.create({
data: {
questionId,
userId,
vote,
},
}),
ctx.prisma.questionsQuestion.update({
data: {
upvotes: {
increment: incrementValue,
},
},
return await ctx.prisma.$transaction(async (tx) => {
const questionToUpdate = await tx.questionsQuestion.findUnique({
where: {
id: questionId,
},
}),
]);
return questionVote;
});
if (questionToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question do not exist.',
});
}
const vote = await tx.questionsQuestionVote.findUnique({
where: {
questionId_userId: { questionId, userId },
},
});
if (vote === null) {
const createdVote = await tx.questionsQuestionVote.create({
data: {
questionId,
userId,
vote: Vote.UPVOTE,
},
});
await tx.questionsQuestion.update({
data: {
upvotes: {
increment: 1,
},
},
where: {
id: questionId,
},
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.UPVOTE) {
return vote;
}
if (vote.vote === Vote.DOWNVOTE) {
const updatedVote = await tx.questionsQuestionVote.update({
data: {
questionId,
userId,
vote: Vote.UPVOTE,
},
where: {
id: vote.id,
},
});
await tx.questionsQuestion.update({
data: {
upvotes: {
increment: 2,
},
},
where: {
id: questionId,
},
});
return updatedVote;
}
});
},
})
.mutation('updateVote', {
.mutation('setDownVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
questionId: z.string(),
}),
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,
},
});
const { questionId } = input;
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
return await ctx.prisma.$transaction(async (tx) => {
const questionToUpdate = await tx.questionsQuestion.findUnique({
where: {
id: questionId,
},
});
}
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
if (questionToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question do not exist.',
});
}
const [questionVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionVote.update({
data: {
vote,
},
const vote = await tx.questionsQuestionVote.findUnique({
where: {
id,
questionId_userId: { questionId, userId },
},
}),
ctx.prisma.questionsQuestion.update({
data: {
upvotes: {
increment: incrementValue,
});
if (vote === null) {
const createdVote = await tx.questionsQuestionVote.create({
data: {
questionId,
userId,
vote: Vote.DOWNVOTE,
},
},
where: {
id: voteToUpdate.questionId,
},
}),
]);
});
await tx.questionsQuestion.update({
data: {
upvotes: {
increment: -1,
},
},
where: {
id: questionId,
},
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote.vote === Vote.DOWNVOTE) {
return vote;
}
if (vote.vote === Vote.UPVOTE) {
const updatedVote = await tx.questionsQuestionVote.update({
data: {
questionId,
userId,
vote: Vote.DOWNVOTE,
},
where: {
id: vote.id,
},
});
return questionVote;
await tx.questionsQuestion.update({
data: {
upvotes: {
increment: -2,
},
},
where: {
id: questionId,
},
});
return updatedVote;
}
});
},
})
.mutation('deleteVote', {
.mutation('setNoVote', {
input: z.object({
id: z.string(),
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionId } = input;
const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
id: input.id,
},
});
return await ctx.prisma.$transaction(async (tx) => {
const questionToUpdate = await tx.questionsQuestion.findUnique({
where: {
id: questionId,
},
});
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
if (questionToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question do not exist.',
});
}
const voteToDelete = await tx.questionsQuestionVote.findUnique({
where: {
questionId_userId: { questionId, userId },
},
});
}
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
if (voteToDelete === null) {
return null;
}
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({
await tx.questionsQuestionVote.delete({
where: {
id: input.id,
id: voteToDelete.id,
},
}),
ctx.prisma.questionsQuestion.update({
});
await tx.questionsQuestion.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.questionId,
id: questionId,
},
}),
]);
return questionVote;
});
return voteToDelete;
});
},
});

@ -14,10 +14,40 @@ export type Question = {
user: string;
};
export type StateInfo = {
cityCounts: Record<string, number>;
total: number;
};
export type CountryInfo = {
stateInfos: Record<string, StateInfo>;
total: number;
};
export type CityLocation = {
cityId: string;
countryId: string;
stateId: string;
};
export type StateLocation = {
cityId?: never;
countryId: string;
stateId: string;
};
export type CountryLocation = {
cityId?: never;
countryId: string;
stateId?: never;
};
export type Location = CityLocation | CountryLocation | StateLocation;
export type AggregatedQuestionEncounter = {
companyCounts: Record<string, number>;
countryCounts: Record<string, CountryInfo>;
latestSeenAt: Date;
locationCounts: Record<string, number>;
roleCounts: Record<string, number>;
};

@ -63,47 +63,6 @@ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
},
] as const;
export const LOCATIONS: FilterChoices = [
{
id: 'Singapore',
label: 'Singapore',
value: 'Singapore',
},
{
id: 'Menlo Park',
label: 'Menlo Park',
value: 'Menlo Park',
},
{
id: 'California',
label: 'California',
value: 'California',
},
{
id: 'Hong Kong',
label: 'Hong Kong',
value: 'Hong Kong',
},
{
id: 'Taiwan',
label: 'Taiwan',
value: 'Taiwan',
},
] as const;
export const ROLES: FilterChoices = [
{
id: 'Software Engineer',
label: 'Software Engineer',
value: 'Software Engineer',
},
{
id: 'Software Engineer Intern',
label: 'Software Engineer Intern',
value: 'Software Engineer Intern',
},
] as const;
export const SORT_ORDERS = [
{
label: 'Ascending',

@ -3,10 +3,8 @@ import { JobTitleLabels } from '~/components/shared/JobTitles';
import type { AggregatedQuestionEncounter } from '~/types/questions';
export default function relabelQuestionAggregates({
locationCounts,
companyCounts,
roleCounts,
latestSeenAt,
...rest
}: AggregatedQuestionEncounter) {
const newRoleCounts = Object.fromEntries(
Object.entries(roleCounts).map(([roleId, count]) => [
@ -16,10 +14,8 @@ export default function relabelQuestionAggregates({
);
const relabeledAggregate: AggregatedQuestionEncounter = {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts: newRoleCounts,
...rest,
};
return relabeledAggregate;

@ -1,17 +1,26 @@
import type {
City,
Company,
Country,
QuestionsQuestion,
QuestionsQuestionVote,
State,
} from '@prisma/client';
import { Vote } from '@prisma/client';
import type { AggregatedQuestionEncounter, Question } from '~/types/questions';
import type {
AggregatedQuestionEncounter,
CountryInfo,
Question,
} from '~/types/questions';
type AggregatableEncounters = Array<{
city: City | null;
company: Company | null;
location: string;
country: Country | null;
role: string;
seenAt: Date;
state: State | null;
}>;
type QuestionWithAggregatableData = QuestionsQuestion & {
@ -67,8 +76,8 @@ export function createQuestionWithAggregateData(
export function createAggregatedQuestionEncounter(
encounters: AggregatableEncounters,
): AggregatedQuestionEncounter {
const countryCounts: Record<string, CountryInfo> = {};
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = encounters[0].seenAt;
@ -77,15 +86,47 @@ export function createAggregatedQuestionEncounter(
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 0;
if (encounter.company !== null) {
if (!(encounter.company.name in companyCounts)) {
companyCounts[encounter.company!.name] = 0;
}
companyCounts[encounter.company!.name] += 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 0;
if (encounter.country !== null) {
if (!(encounter.country.name in countryCounts)) {
countryCounts[encounter.country.name] = {
stateInfos: {},
total: 0,
};
}
const countryInfo = countryCounts[encounter.country.name];
countryInfo.total += 1;
const countryStateInfo = countryInfo.stateInfos;
if (encounter.state !== null) {
if (!(encounter.state.name in countryStateInfo)) {
countryStateInfo[encounter.state.name] = {
cityCounts: {},
total: 0,
};
}
const stateInfo = countryStateInfo[encounter.state.name];
stateInfo.total += 1;
const { cityCounts } = stateInfo;
if (encounter.city !== null) {
if (!(encounter.city.name in cityCounts)) {
cityCounts[encounter.city.name] = 0;
}
cityCounts[encounter.city.name] += 1;
}
}
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 0;
@ -95,8 +136,8 @@ export function createAggregatedQuestionEncounter(
return {
companyCounts,
countryCounts,
latestSeenAt,
locationCounts,
roleCounts,
};
}

@ -1,7 +1,31 @@
import type { FilterChoice } from '~/components/questions/filter/FilterSection';
import { LOCATIONS } from './constants';
import { trpc } from '../trpc';
export default function useDefaultLocation(): FilterChoice | undefined {
return LOCATIONS[0];
import type { Location } from '~/types/questions';
export default function useDefaultLocation():
| (FilterChoice & Location)
| undefined {
const { data: locations } = trpc.useQuery([
'locations.cities.list',
{
name: 'singapore',
},
]);
if (locations === undefined) {
return undefined;
}
const { id, name, state } = locations[0];
return {
cityId: id,
countryId: state.country.id,
id,
label: `${name}, ${state.name}, ${state.country.name}`,
stateId: state.id,
value: id,
};
}

@ -5,9 +5,9 @@ import type { Vote } from '@prisma/client';
import { trpc } from '../trpc';
type UseVoteOptions = {
createVote: (opts: { vote: Vote }) => void;
deleteVote: (opts: { id: string }) => void;
updateVote: (opts: BackendVote) => void;
setDownVote: () => void;
setNoVote: () => void;
setUpVote: () => void;
};
type BackendVote = {
@ -19,47 +19,23 @@ const createVoteCallbacks = (
vote: BackendVote | null,
opts: UseVoteOptions,
) => {
const { createVote, updateVote, deleteVote } = opts;
const { setDownVote, setNoVote, setUpVote } = opts;
const handleUpvote = () => {
// Either upvote or remove upvote
if (vote) {
if (vote.vote === 'DOWNVOTE') {
updateVote({
id: vote.id,
vote: 'UPVOTE',
});
} else {
deleteVote({
id: vote.id,
});
}
// Update vote to an upvote
if (vote && vote.vote === 'UPVOTE') {
setNoVote();
} else {
createVote({
vote: 'UPVOTE',
});
setUpVote();
}
};
const handleDownvote = () => {
// Either downvote or remove downvote
if (vote) {
if (vote.vote === 'UPVOTE') {
updateVote({
id: vote.id,
vote: 'DOWNVOTE',
});
} else {
deleteVote({
id: vote.id,
});
}
// Update vote to an upvote
if (vote && vote.vote === 'DOWNVOTE') {
setNoVote();
} else {
createVote({
vote: 'DOWNVOTE',
});
setDownVote();
}
};
@ -71,61 +47,61 @@ type QueryKey = Parameters<typeof trpc.useQuery>[0][0];
export const useQuestionVote = (id: string) => {
return useVote(id, {
create: 'questions.questions.user.createVote',
deleteKey: 'questions.questions.user.deleteVote',
idKey: 'questionId',
invalidateKeys: [
'questions.questions.getQuestionsByFilter',
'questions.questions.getQuestionById',
],
query: 'questions.questions.user.getVote',
update: 'questions.questions.user.updateVote',
setDownVoteKey: 'questions.questions.user.setDownVote',
setNoVoteKey: 'questions.questions.user.setNoVote',
setUpVoteKey: 'questions.questions.user.setUpVote',
});
};
export const useAnswerVote = (id: string) => {
return useVote(id, {
create: 'questions.answers.user.createVote',
deleteKey: 'questions.answers.user.deleteVote',
idKey: 'answerId',
invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById',
],
query: 'questions.answers.user.getVote',
update: 'questions.answers.user.updateVote',
setDownVoteKey: 'questions.answers.user.setDownVote',
setNoVoteKey: 'questions.answers.user.setNoVote',
setUpVoteKey: 'questions.answers.user.setUpVote',
});
};
export const useQuestionCommentVote = (id: string) => {
return useVote(id, {
create: 'questions.questions.comments.user.createVote',
deleteKey: 'questions.questions.comments.user.deleteVote',
idKey: 'questionCommentId',
invalidateKeys: ['questions.questions.comments.getQuestionComments'],
query: 'questions.questions.comments.user.getVote',
update: 'questions.questions.comments.user.updateVote',
setDownVoteKey: 'questions.questions.comments.user.setDownVote',
setNoVoteKey: 'questions.questions.comments.user.setNoVote',
setUpVoteKey: 'questions.questions.comments.user.setUpVote',
});
};
export const useAnswerCommentVote = (id: string) => {
return useVote(id, {
create: 'questions.answers.comments.user.createVote',
deleteKey: 'questions.answers.comments.user.deleteVote',
idKey: 'answerCommentId',
invalidateKeys: ['questions.answers.comments.getAnswerComments'],
query: 'questions.answers.comments.user.getVote',
update: 'questions.answers.comments.user.updateVote',
setDownVoteKey: 'questions.answers.comments.user.setDownVote',
setNoVoteKey: 'questions.answers.comments.user.setNoVote',
setUpVoteKey: 'questions.answers.comments.user.setUpVote',
});
};
type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
create: MutationKey;
deleteKey: MutationKey;
idKey: string;
invalidateKeys: Array<VoteQueryKey>;
query: VoteQueryKey;
update: MutationKey;
setDownVoteKey: MutationKey;
setNoVoteKey: MutationKey;
setUpVoteKey: MutationKey;
};
type UseVoteMutationContext = {
@ -137,7 +113,14 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
id: string,
opts: VoteProps<VoteQueryKey>,
) => {
const { create, deleteKey, query, update, idKey, invalidateKeys } = opts;
const {
idKey,
invalidateKeys,
query,
setDownVoteKey,
setNoVoteKey,
setUpVoteKey,
} = opts;
const utils = trpc.useContext();
const onVoteUpdate = useCallback(() => {
@ -157,8 +140,8 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const backendVote = data as BackendVote;
const { mutate: createVote } = trpc.useMutation<any, UseVoteMutationContext>(
create,
const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>(
setUpVoteKey,
{
onError: (err, variables, context) => {
if (context !== undefined) {
@ -185,8 +168,8 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
onSettled: onVoteUpdate,
},
);
const { mutate: updateVote } = trpc.useMutation<any, UseVoteMutationContext>(
update,
const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>(
setDownVoteKey,
{
onError: (error, variables, context) => {
if (context !== undefined) {
@ -214,8 +197,8 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
},
);
const { mutate: deleteVote } = trpc.useMutation<any, UseVoteMutationContext>(
deleteKey,
const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>(
setNoVoteKey,
{
onError: (err, variables, context) => {
if (context !== undefined) {
@ -242,14 +225,21 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { handleDownvote, handleUpvote } = createVoteCallbacks(
backendVote ?? null,
{
createVote: ({ vote }) => {
createVote({
setDownVote: () => {
setDownVote({
[idKey]: id,
vote,
} as any);
});
},
setNoVote: () => {
setNoVote({
[idKey]: id,
});
},
setUpVote: () => {
setUpVote({
[idKey]: id,
});
},
deleteVote,
updateVote,
},
);

Loading…
Cancel
Save