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

@ -28,54 +28,56 @@ export default function ContributeQuestionCard({
}; };
return ( return (
<button <div className="w-full">
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" <button
type="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"
onClick={handleOpenContribute}> type="button"
<TextInput onClick={handleOpenContribute}>
disabled={true} <TextInput
isLabelHidden={true} disabled={true}
label="Question" isLabelHidden={true}
placeholder="Contribute a question" label="Question"
onChange={handleOpenContribute} 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"> <div className="flex flex-wrap items-end justify-center gap-x-2">
<TextInput <div className="min-w-[150px] flex-1">
disabled={true} <TextInput
label="Company" disabled={true}
startAddOn={BuildingOffice2Icon} label="Company"
startAddOnType="icon" startAddOn={BuildingOffice2Icon}
onChange={handleOpenContribute} 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>
<div className="min-w-[150px] flex-1"> <ContributeQuestionDialog
<TextInput show={showDraftDialog}
disabled={true} onCancel={handleDraftDialogCancel}
label="Question type" onSubmit={onSubmit}
startAddOn={QuestionMarkCircleIcon} />
startAddOnType="icon" </button>
onChange={handleOpenContribute} </div>
/>
</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>
); );
} }

@ -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, AdjustmentsHorizontalIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui'; import { Button, TextInput } from '@tih/ui';
export type SortOption<Value> = { import type { SortOptionsSelectProps } from './SortOptionsSelect';
label: string; import SortOptionsSelect from './SortOptionsSelect';
value: Value;
};
type SortOrderProps<SortOrder> = {
onSortOrderChange?: (sortValue: SortOrder) => void;
sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
sortOrderValue: SortOrder;
};
type SortTypeProps<SortType> = { export type QuestionSearchBarProps = SortOptionsSelectProps & {
onSortTypeChange?: (sortType: SortType) => void; onFilterOptionsToggle: () => void;
sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
sortTypeValue: SortType;
}; };
export type QuestionSearchBarProps<SortType, SortOrder> = export default function QuestionSearchBar({
SortOrderProps<SortOrder> &
SortTypeProps<SortType> & {
onFilterOptionsToggle: () => void;
};
export default function QuestionSearchBar<SortType, SortOrder>({
onSortOrderChange,
sortOrderOptions,
sortOrderValue,
onSortTypeChange,
sortTypeOptions,
sortTypeValue,
onFilterOptionsToggle, onFilterOptionsToggle,
}: QuestionSearchBarProps<SortType, SortOrder>) { ...sortOptionsSelectProps
}: QuestionSearchBarProps) {
return ( return (
<div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end"> <div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1 "> <div className="flex-1 ">
@ -48,38 +27,7 @@ export default function QuestionSearchBar<SortType, SortOrder>({
/> />
</div> </div>
<div className="flex items-end justify-end gap-4"> <div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2"> <SortOptionsSelect {...sortOptionsSelectProps} />
<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 className="lg:hidden"> <div className="lg:hidden">
<Button <Button
addonPosition="start" 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 clsx from 'clsx';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
ChatBubbleBottomCenterTextIcon, ChatBubbleBottomCenterTextIcon,
CheckIcon, CheckIcon,
@ -18,6 +18,8 @@ import QuestionAggregateBadge from '../../QuestionAggregateBadge';
import QuestionTypeBadge from '../../QuestionTypeBadge'; import QuestionTypeBadge from '../../QuestionTypeBadge';
import VotingButtons from '../../VotingButtons'; import VotingButtons from '../../VotingButtons';
import type { CountryInfo } from '~/types/questions';
type UpvoteProps = type UpvoteProps =
| { | {
showVoteButtons: true; showVoteButtons: true;
@ -51,13 +53,13 @@ type AnswerStatisticsProps =
type AggregateStatisticsProps = type AggregateStatisticsProps =
| { | {
companies: Record<string, number>; companies: Record<string, number>;
locations: Record<string, number>; countries: Record<string, CountryInfo>;
roles: Record<string, number>; roles: Record<string, number>;
showAggregateStatistics: true; showAggregateStatistics: true;
} }
| { | {
companies?: never; companies?: never;
locations?: never; countries?: never;
roles?: never; roles?: never;
showAggregateStatistics?: false; showAggregateStatistics?: false;
}; };
@ -136,7 +138,7 @@ export default function BaseQuestionCard({
upvoteCount, upvoteCount,
timestamp, timestamp,
roles, roles,
locations, countries,
showHover, showHover,
onReceivedSubmit, onReceivedSubmit,
showDeleteButton, showDeleteButton,
@ -147,6 +149,22 @@ export default function BaseQuestionCard({
const [showReceivedForm, setShowReceivedForm] = useState(false); const [showReceivedForm, setShowReceivedForm] = useState(false);
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId); const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
const hoverClass = showHover ? 'hover:bg-slate-50' : ''; 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 = ( const cardContent = (
<> <>
{showVoteButtons && ( {showVoteButtons && (
@ -168,7 +186,7 @@ export default function BaseQuestionCard({
variant="primary" variant="primary"
/> />
<QuestionAggregateBadge <QuestionAggregateBadge
statistics={locations} statistics={locations!}
variant="success" variant="success"
/> />
<QuestionAggregateBadge statistics={roles} variant="danger" /> <QuestionAggregateBadge statistics={roles} variant="danger" />

@ -1,9 +1,10 @@
import { startOfMonth } from 'date-fns'; import { startOfMonth } from 'date-fns';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { Button, HorizontalDivider, Select, TextArea } 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 { import {
useFormRegister, useFormRegister,
useSelectRegister, useSelectRegister,
@ -15,14 +16,16 @@ import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Month } from '../../shared/MonthYearPicker'; import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker'; import MonthYearPicker from '../../shared/MonthYearPicker';
import type { Location } from '~/types/questions';
export type ContributeQuestionData = { export type ContributeQuestionData = {
company: string; company: string;
date: Date; date: Date;
location: string; location: Location & TypeaheadOption;
position: string; position: string;
questionContent: string; questionContent: string;
questionType: QuestionsQuestionType; questionType: QuestionsQuestionType;
role: string; role: TypeaheadOption;
}; };
export type ContributeQuestionFormProps = { export type ContributeQuestionFormProps = {
@ -79,15 +82,12 @@ export default function ContributeQuestionForm({
name="location" name="location"
render={({ field }) => ( render={({ field }) => (
<LocationTypeahead <LocationTypeahead
{...field}
required={true} required={true}
onSelect={(option) => { onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value. // @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 <Controller
control={control} control={control}
name="company" name="company"
render={({ field }) => ( render={({ field: { value: _, ...field } }) => (
<CompanyTypeahead <CompanyTypeahead
{...field}
required={true} required={true}
// @ts-ignore TODO(questions): handle potentially null value. // @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ id }) => { onSelect={({ id }) => {
@ -134,13 +135,12 @@ export default function ContributeQuestionForm({
name="role" name="role"
render={({ field }) => ( render={({ field }) => (
<RoleTypeahead <RoleTypeahead
{...field}
required={true} required={true}
onSelect={(option) => { onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value. // @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 LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead'; import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Location } from '~/types/questions';
export type CreateQuestionEncounterData = { export type CreateQuestionEncounterData = {
cityId?: string;
company: string; company: string;
location: string; countryId: string;
role: string; role: string;
seenAt: Date; seenAt: Date;
stateId?: string;
}; };
export type CreateQuestionEncounterFormProps = { export type CreateQuestionEncounterFormProps = {
@ -28,7 +32,9 @@ export default function CreateQuestionEncounterForm({
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null); 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 [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>( const [selectedDate, setSelectedDate] = useState<Date>(
startOfMonth(new Date()), startOfMonth(new Date()),
@ -61,10 +67,10 @@ export default function CreateQuestionEncounterForm({
placeholder="Other location" placeholder="Other location"
suggestedCount={3} suggestedCount={3}
// @ts-ignore TODO(questions): handle potentially null value. // @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ value: location }) => { onSelect={(location) => {
setSelectedLocation(location); setSelectedLocation(location);
}} }}
onSuggestionClick={({ value: location }) => { onSuggestionClick={(location) => {
setSelectedLocation(location); setSelectedLocation(location);
setStep(step + 1); setStep(step + 1);
}} }}
@ -130,11 +136,14 @@ export default function CreateQuestionEncounterForm({
selectedRole && selectedRole &&
selectedDate selectedDate
) { ) {
const { cityId, stateId, countryId } = selectedLocation;
onSubmit({ onSubmit({
cityId,
company: selectedCompany, company: selectedCompany,
location: selectedLocation, countryId,
role: selectedRole, role: selectedRole,
seenAt: selectedDate, seenAt: selectedDate,
stateId,
}); });
} }
}} }}

@ -8,13 +8,16 @@ import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
type TypeaheadProps = ComponentProps<typeof Typeahead>; type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number]; type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = RequireAllOrNone<{ export type ExpandedTypeaheadProps = Omit<TypeaheadProps, 'onSelect'> &
clearOnSelect?: boolean; RequireAllOrNone<{
filterOption: (option: TypeaheadOption) => boolean; clearOnSelect?: boolean;
onSuggestionClick: (option: TypeaheadOption) => void; filterOption: (option: TypeaheadOption) => boolean;
suggestedCount: number; onSuggestionClick: (option: TypeaheadOption) => void;
}> & suggestedCount: number;
TypeaheadProps; }> & {
onChange?: unknown; // Workaround: This prop is here just to absorb the onChange returned react-hook-form
onSelect: (option: TypeaheadOption) => void;
};
export default function ExpandedTypeahead({ export default function ExpandedTypeahead({
suggestedCount = 0, suggestedCount = 0,
@ -23,6 +26,7 @@ export default function ExpandedTypeahead({
clearOnSelect = false, clearOnSelect = false,
options, options,
onSelect, onSelect,
onChange: _,
...typeaheadProps ...typeaheadProps
}: ExpandedTypeaheadProps) { }: ExpandedTypeaheadProps) {
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
@ -55,7 +59,8 @@ export default function ExpandedTypeahead({
if (clearOnSelect) { if (clearOnSelect) {
setKey((key + 1) % 2); setKey((key + 1) % 2);
} }
onSelect(option); // TODO: Remove onSelect null coercion once onSelect prop is refactored
onSelect(option!);
}} }}
/> />
</div> </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 type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead'; import ExpandedTypeahead from './ExpandedTypeahead';
import type { Location } from '~/types/questions';
export type LocationTypeaheadProps = Omit< export type LocationTypeaheadProps = Omit<
ExpandedTypeaheadProps, 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 ( return (
<ExpandedTypeahead <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" label="Location"
options={LOCATIONS} options={locationOptions}
// eslint-disable-next-line @typescript-eslint/no-empty-function onQueryChange={setQuery}
onQueryChange={() => {}} 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 { JobTitleLabels } from '~/components/shared/JobTitles';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead'; import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
@ -17,13 +19,16 @@ const ROLES: FilterChoices = Object.entries(JobTitleLabels).map(
}), }),
); );
export default function RoleTypeahead(props: RoleTypeaheadProps) { export default function RoleTypeahead(props: RoleTypeaheadProps) {
const [query, setQuery] = useState('');
return ( return (
<ExpandedTypeahead <ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)} {...(props as ExpandedTypeaheadProps)}
label="Role" label="Role"
options={ROLES} options={ROLES.filter((option) =>
// eslint-disable-next-line @typescript-eslint/no-empty-function option.label.toLowerCase().includes(query.toLowerCase()),
onQueryChange={() => {}} )}
onQueryChange={setQuery}
/> />
); );
} }

@ -1,17 +1,22 @@
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline'; 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 AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard'; import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner'; 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 { APP_TITLE } from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister'; import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import { SortOrder, SortType } from '~/types/questions.d';
export type AnswerCommentData = { export type AnswerCommentData = {
commentContent: string; commentContent: string;
}; };
@ -19,6 +24,13 @@ export type AnswerCommentData = {
export default function QuestionPage() { export default function QuestionPage() {
const router = useRouter(); const router = useRouter();
const [commentSortOrder, setCommentSortOrder] = useState<SortOrder>(
SortOrder.DESC,
);
const [commentSortType, setCommentSortType] = useState<SortType>(
SortType.NEW,
);
const { const {
register: comRegister, register: comRegister,
reset: resetComment, reset: resetComment,
@ -36,10 +48,23 @@ export default function QuestionPage() {
{ answerId: answerId as string }, { answerId: answerId as string },
]); ]);
const { data: comments } = trpc.useQuery([ const answerCommentInfiniteQuery = trpc.useInfiniteQuery(
'questions.answers.comments.getAnswerComments', [
{ answerId: answerId as string }, '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( const { mutate: addComment } = trpc.useMutation(
'questions.answers.comments.user.create', 'questions.answers.comments.user.create',
@ -47,7 +72,11 @@ export default function QuestionPage() {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries([ utils.invalidateQueries([
'questions.answers.comments.getAnswerComments', '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} rows={2}
/> />
<div className="my-3 flex justify-between"> <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 <Button
disabled={!isCommentDirty || !isCommentValid} disabled={!isCommentDirty || !isCommentValid}
label="Post" label="Post"
@ -142,18 +145,35 @@ export default function QuestionPage() {
/> />
</div> </div>
</form> </form>
<div className="flex flex-col gap-2">
{(comments ?? []).map((comment) => ( <div className="flex items-center justify-between gap-2">
<AnswerCommentListItem <p className="text-lg">Comments</p>
key={comment.id} <div className="flex items-end gap-2">
answerCommentId={comment.id} <SortOptionsSelect
authorImageUrl={comment.userImage} sortOrderValue={commentSortOrder}
authorName={comment.user} sortTypeValue={commentSortType}
content={comment.content} onSortOrderChange={setCommentSortOrder}
createdAt={comment.createdAt} onSortTypeChange={setCommentSortType}
upvoteCount={comment.numVotes} />
/> </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> </div>
</div> </div>

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

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

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

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

@ -4,16 +4,43 @@ import { Vote } from '@prisma/client';
import { createRouter } from '../context'; import { createRouter } from '../context';
import type { AnswerComment } from '~/types/questions'; import type { AnswerComment } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsAnswerCommentRouter = createRouter().query( export const questionsAnswerCommentRouter = createRouter().query(
'getAnswerComments', 'getAnswerComments',
{ {
input: z.object({ input: z.object({
answerId: z.string(), 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 }) { 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 = const questionAnswerCommentsData =
await ctx.prisma.questionsAnswerComment.findMany({ await ctx.prisma.questionsAnswerComment.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: { include: {
user: { user: {
select: { select: {
@ -23,14 +50,13 @@ export const questionsAnswerCommentRouter = createRouter().query(
}, },
votes: true, votes: true,
}, },
orderBy: { orderBy: sortCondition,
createdAt: 'desc', take: input.limit + 1,
},
where: { where: {
answerId: input.answerId, answerId,
}, },
}); });
return questionAnswerCommentsData.map((data) => { const processedQuestionAnswerCommentsData = questionAnswerCommentsData.map((data) => {
const votes: number = data.votes.reduce( const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => { (previousValue: number, currentValue) => {
let result: number = previousValue; let result: number = previousValue;
@ -59,6 +85,22 @@ export const questionsAnswerCommentRouter = createRouter().query(
}; };
return answerComment; 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({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', 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({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -100,130 +100,248 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter()
}); });
}, },
}) })
.mutation('createVote', { .mutation('setUpVote', {
input: z.object({ input: z.object({
answerCommentId: z.string(), answerCommentId: z.string(),
vote: z.nativeEnum(Vote),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; 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([ const vote = await tx.questionsAnswerCommentVote.findUnique({
ctx.prisma.questionsAnswerCommentVote.create({
data: {
answerCommentId,
userId,
vote,
},
}),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: { 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({ input: z.object({
id: z.string(), answerCommentId: z.string(),
vote: z.nativeEnum(Vote),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; 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 = const vote = await tx.questionsAnswerCommentVote.findUnique({
await ctx.prisma.questionsAnswerCommentVote.findUnique({
where: { where: {
id: input.id, answerCommentId_userId: { answerCommentId, userId },
}, },
}); });
if (voteToUpdate?.userId !== userId) { if (vote === null) {
throw new TRPCError({ const createdVote = await tx.questionsAnswerCommentVote.create({
code: 'UNAUTHORIZED', data: {
message: 'User have no authorization to record.', 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([ return createdVote;
ctx.prisma.questionsAnswerCommentVote.update({ }
data: {
vote, if (vote!.userId !== userId) {
}, throw new TRPCError({
where: { code: 'UNAUTHORIZED',
id, message: 'User have no authorization to record.',
}, });
}), }
ctx.prisma.questionsAnswerComment.update({
data: { if (vote!.vote === Vote.DOWNVOTE) {
upvotes: { return vote;
increment: incrementValue, }
if (vote.vote === Vote.UPVOTE) {
const updatedVote = await tx.questionsAnswerCommentVote.update({
data: {
answerCommentId,
userId,
vote: Vote.DOWNVOTE,
}, },
}, where: {
where: { id: vote.id,
id: voteToUpdate.answerCommentId, },
}, });
}),
]);
return answerCommentVote; await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: -2,
},
},
where: {
id: answerCommentId,
},
});
return updatedVote;
}
});
}, },
}) })
.mutation('deleteVote', { .mutation('setNoVote', {
input: z.object({ input: z.object({
id: z.string(), answerCommentId: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; 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 = const voteToDelete = await tx.questionsAnswerCommentVote.findUnique({
await ctx.prisma.questionsAnswerCommentVote.findUnique({
where: { where: {
id: input.id, answerCommentId_userId: { answerCommentId, userId },
}, },
}); });
if (voteToDelete?.userId !== userId) { if (voteToDelete === null) {
throw new TRPCError({ return null;
code: 'UNAUTHORIZED', }
message: 'User have no authorization to record.',
}); 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([ await tx.questionsAnswerCommentVote.delete({
ctx.prisma.questionsAnswerCommentVote.delete({
where: { where: {
id: input.id, id: voteToDelete.id,
}, },
}), });
ctx.prisma.questionsAnswerComment.update({
await tx.questionsAnswerComment.update({
data: { data: {
upvotes: { upvotes: {
increment: incrementValue, increment: incrementValue,
}, },
}, },
where: { where: {
id: voteToDelete.answerCommentId, id: answerCommentId,
}, },
}), });
]);
return answerCommentVote; return voteToDelete;
});
}, },
}); });

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

@ -39,7 +39,7 @@ export const questionsAnswerUserRouter = createProtectedRouter()
}, },
}); });
if (answerToUpdate?.id !== userId) { if (answerToUpdate?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', 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({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -98,127 +98,245 @@ export const questionsAnswerUserRouter = createProtectedRouter()
}); });
}, },
}) })
.mutation('createVote', { .mutation('setUpVote', {
input: z.object({ input: z.object({
answerId: z.string(), answerId: z.string(),
vote: z.nativeEnum(Vote),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; 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([ const vote = await tx.questionsAnswerVote.findUnique({
ctx.prisma.questionsAnswerVote.create({
data: {
answerId,
userId,
vote,
},
}),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: { 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({ input: z.object({
id: z.string(), answerId: z.string(),
vote: z.nativeEnum(Vote),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { id, vote } = input; const { answerId } = input;
const voteToUpdate = await ctx.prisma.questionsAnswerVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.userId !== userId) { return await ctx.prisma.$transaction(async (tx) => {
throw new TRPCError({ const answerToUpdate = await tx.questionsAnswer.findUnique({
code: 'UNAUTHORIZED', where: {
message: 'User have no authorization to record.', 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([ const vote = await tx.questionsAnswerVote.findUnique({
ctx.prisma.questionsAnswerVote.update({
data: {
vote,
},
where: { where: {
id, answerId_userId: { answerId, userId },
}, },
}), });
ctx.prisma.questionsAnswer.update({
data: { if (vote === null) {
upvotes: { const createdVote = await tx.questionsAnswerVote.create({
increment: incrementValue, 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({ input: z.object({
id: z.string(), answerId: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { answerId } = input;
const voteToDelete = await ctx.prisma.questionsAnswerVote.findUnique({ return await ctx.prisma.$transaction(async (tx) => {
where: { const answerToUpdate = await tx.questionsAnswer.findUnique({
id: input.id, where: {
}, id: answerId,
}); },
});
if (voteToDelete?.userId !== userId) { if (answerToUpdate === null) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'BAD_REQUEST',
message: 'User have no authorization to record.', 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([ if (voteToDelete!.userId !== userId) {
ctx.prisma.questionsAnswerVote.delete({ 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: { where: {
id: input.id, id: voteToDelete.id,
}, },
}), });
ctx.prisma.questionsAnswer.update({
await tx.questionsAnswer.update({
data: { data: {
upvotes: { upvotes: {
increment: incrementValue, increment: incrementValue,
}, },
}, },
where: { where: {
id: voteToDelete.answerId, id: answerId,
}, },
}), });
]);
return questionsAnswerVote; return voteToDelete;
});
}, },
}); });

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

@ -4,17 +4,43 @@ import { Vote } from '@prisma/client';
import { createRouter } from '../context'; import { createRouter } from '../context';
import type { QuestionComment } from '~/types/questions'; import type { QuestionComment } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionCommentRouter = createRouter().query( export const questionsQuestionCommentRouter = createRouter().query(
'getQuestionComments', 'getQuestionComments',
{ {
input: z.object({ input: z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).default(50),
questionId: z.string(), questionId: z.string(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
}), }),
async resolve({ ctx, input }) { 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 = const questionCommentsData =
await ctx.prisma.questionsQuestionComment.findMany({ await ctx.prisma.questionsQuestionComment.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: { include: {
user: { user: {
select: { select: {
@ -24,14 +50,13 @@ export const questionsQuestionCommentRouter = createRouter().query(
}, },
votes: true, votes: true,
}, },
orderBy: { orderBy: sortCondition,
createdAt: 'desc', take: input.limit + 1,
},
where: { where: {
questionId, questionId,
}, },
}); });
return questionCommentsData.map((data) => { const processedQuestionCommentsData = questionCommentsData.map((data) => {
const votes: number = data.votes.reduce( const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => { (previousValue: number, currentValue) => {
let result: number = previousValue; let result: number = previousValue;
@ -59,6 +84,22 @@ export const questionsQuestionCommentRouter = createRouter().query(
}; };
return questionComment; 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({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', 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({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -101,128 +101,251 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter()
}); });
}, },
}) })
.mutation('createVote', { .mutation('setUpVote', {
input: z.object({ input: z.object({
questionCommentId: z.string(), questionCommentId: z.string(),
vote: z.nativeEnum(Vote),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { questionCommentId, vote } = input; const { questionCommentId } = input;
const incrementValue: number = vote === Vote.UPVOTE ? 1 : -1;
const [questionCommentVote] = await ctx.prisma.$transaction([ return await ctx.prisma.$transaction(async (tx) => {
ctx.prisma.questionsQuestionCommentVote.create({ const questionCommentToUpdate =
data: { await tx.questionsQuestionComment.findUnique({
questionCommentId, where: {
userId, id: questionCommentId,
vote,
},
}),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
}, },
}, });
if (questionCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question Comment do not exist.',
});
}
const vote = await tx.questionsQuestionCommentVote.findUnique({
where: { 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({ input: z.object({
id: z.string(), questionCommentId: z.string(),
vote: z.nativeEnum(Vote),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { id, vote } = input; const { questionCommentId } = input;
const voteToUpdate = return await ctx.prisma.$transaction(async (tx) => {
await ctx.prisma.questionsQuestionCommentVote.findUnique({ 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: { where: {
id: input.id, questionCommentId_userId: { questionCommentId, userId },
}, },
}); });
if (voteToUpdate?.userId !== userId) { if (vote === null) {
throw new TRPCError({ const createdVote = await tx.questionsQuestionCommentVote.create({
code: 'UNAUTHORIZED', data: {
message: 'User have no authorization to record.', 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([ return createdVote;
ctx.prisma.questionsQuestionCommentVote.update({ }
data: {
vote, if (vote!.userId !== userId) {
}, throw new TRPCError({
where: { code: 'UNAUTHORIZED',
id, message: 'User have no authorization to record.',
}, });
}), }
ctx.prisma.questionsQuestionComment.update({
data: { if (vote!.vote === Vote.DOWNVOTE) {
upvotes: { return vote;
increment: incrementValue, }
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({ input: z.object({
id: z.string(), questionCommentId: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; 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 = if (questionCommentToUpdate === null) {
await ctx.prisma.questionsQuestionCommentVote.findUnique({ throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question Comment do not exist.',
});
}
const voteToDelete = await tx.questionsQuestionCommentVote.findUnique({
where: { where: {
id: input.id, questionCommentId_userId: { questionCommentId, userId },
}, },
}); });
if (voteToDelete?.userId !== userId) { if (voteToDelete === null) {
throw new TRPCError({ return null;
code: 'UNAUTHORIZED', }
message: 'User have no authorization to record.',
}); 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([ await tx.questionsQuestionCommentVote.delete({
ctx.prisma.questionsQuestionCommentVote.delete({
where: { where: {
id: input.id, id: voteToDelete.id,
}, },
}), });
ctx.prisma.questionsQuestionComment.update({
await tx.questionsQuestionComment.update({
data: { data: {
upvotes: { upvotes: {
increment: incrementValue, increment: incrementValue,
}, },
}, },
where: { where: {
id: voteToDelete.questionCommentId, id: questionCommentId,
}, },
}), });
]);
return questionCommentVote; return voteToDelete;
});
}, },
}); });

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

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

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

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

@ -14,10 +14,40 @@ export type Question = {
user: string; 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 = { export type AggregatedQuestionEncounter = {
companyCounts: Record<string, number>; companyCounts: Record<string, number>;
countryCounts: Record<string, CountryInfo>;
latestSeenAt: Date; latestSeenAt: Date;
locationCounts: Record<string, number>;
roleCounts: Record<string, number>; roleCounts: Record<string, number>;
}; };

@ -63,47 +63,6 @@ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
}, },
] as const; ] 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 = [ export const SORT_ORDERS = [
{ {
label: 'Ascending', label: 'Ascending',

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

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

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

Loading…
Cancel
Save