diff --git a/apps/portal/package.json b/apps/portal/package.json index a8a184d8..303c6163 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -9,7 +9,8 @@ "lint": "next lint", "tsc": "tsc", "postinstall": "prisma generate", - "seed": "ts-node prisma/seed.ts" + "seed": "ts-node prisma/seed.ts", + "seed-questions": "ts-node prisma/seed-questions.ts" }, "dependencies": { "@headlessui/react": "^1.7.3", diff --git a/apps/portal/prisma/seed-questions.ts b/apps/portal/prisma/seed-questions.ts new file mode 100644 index 00000000..6f8ebbf4 --- /dev/null +++ b/apps/portal/prisma/seed-questions.ts @@ -0,0 +1,207 @@ +import { PrismaClient } from '@prisma/client'; + +import { JobTitleLabels } from '../src/components/shared/JobTitles'; + +const prisma = new PrismaClient(); + +type QuestionCreateData = Parameters< + typeof prisma.questionsQuestion.create +>[0]['data']; + +function selectRandomRole() { + const roles = Object.keys(JobTitleLabels); + const randomIndex = Math.floor(Math.random() * roles.length); + return roles[randomIndex]; +} + +function generateRandomDate() { + // Return a date between 2020 and 2022. + const start = new Date(2020, 0, 1); + const end = new Date(2022, 0, 1); + return new Date( + start.getTime() + Math.random() * (end.getTime() - start.getTime()), + ); +} + +function generateRandomCodingAnswer() { + return CODING_ANSWER_CONTENT[ + Math.floor(Math.random() * CODING_ANSWER_CONTENT.length) + ]; +} + +function generateRandomBehavioralAnswer() { + return BEHAVIORAL_ANSWER_CONTENT[ + Math.floor(Math.random() * BEHAVIORAL_ANSWER_CONTENT.length) + ]; +} + +const CODING_QUESTION_CONTENT = [ + 'Given a string, find the length of the longest substring without repeating characters.', + 'Given an array of integers, return indices of the two numbers such that they add up to a specific target.', + 'Given a contiguous sequence of numbers in which each number repeats thrice, there is exactly one missing number. Find the missing number.', + 'Find the contiguous subarray within an array (containing at least one number) which has the largest product.', + 'Find a contiguous subarray which has the largest sum.', +]; + +const BEHAVIORAL_QUESTION_CONTENT = [ + 'Tell me about a time you had to work with a difficult person.', + 'Rate your communication skills on a scale of 1 to 10.', + 'Are you a team player?', + 'What is your greatest weakness?', + 'What is your greatest strength?', + 'What is your biggest accomplishment?', + 'What is your biggest failure?', + 'Be honest, how would your friends describe you?', + 'How do you handle stress?', + 'Let’s say you have a deadline to meet. How do you prioritize your work?', +]; + +const CODING_ANSWER_CONTENT = [ + 'This question is easy. Just use a hash map.', + 'This question is hard. I have no idea how to solve it.', + 'This question is medium. I can solve it in 30 minutes.', + 'Can be done with a simple for loop.', + 'Simple recursion can solve this.', + 'Please explain the question again.', + 'Question is not clear.', + 'Brute force solution is the best.', +]; + +const BEHAVIORAL_ANSWER_CONTENT = [ + 'This is a very common question. I have a lot of experience with this.', + "I don't think this is a good question to ask. However, I can answer it.", + 'Most companies ask this question. I think you should ask something else.', + 'I try to take a step back and assess the situation. I figure out what is the most important thing to do and what can wait. I also try to delegate or ask for help when needed.', + 'I try to have a discussion with my manager or the person who I feel is not valuing my work. I try to explain how I feel and what I would like to see change.', + 'I try to have a discussion with the coworker. I try to understand their perspective and see if there is a way to resolve the issue.', +]; + +const CODING_QUESTIONS: Array = CODING_QUESTION_CONTENT.map( + (content) => ({ + content, + questionType: 'CODING', + userId: null, + encounters: { + create: { + location: 'Singapore', + role: selectRandomRole(), + seenAt: generateRandomDate(), + }, + }, + }), +); + +const BEHAVIORAL_QUESTIONS: Array = + BEHAVIORAL_QUESTION_CONTENT.map((content) => ({ + content, + questionType: 'BEHAVIORAL', + userId: null, + encounters: { + create: { + location: 'Singapore', + role: selectRandomRole(), + seenAt: generateRandomDate(), + }, + }, + })); + +const QUESTIONS: Array = [ + ...CODING_QUESTIONS, + ...BEHAVIORAL_QUESTIONS, +]; + +async function main() { + console.log('Performing preliminary checks...'); + + const firstCompany = await prisma.company.findFirst(); + if (!firstCompany) { + throw new Error( + 'No company found. Please seed db with some companies first.', + ); + } + + // Generate random answers to the questions + const users = await prisma.user.findMany(); + if (users.length === 0) { + throw new Error('No users found. Please seed db with some users first.'); + } + + console.log('Seeding started...'); + + console.log('Creating coding and behavioral questions...'); + await Promise.all([ + QUESTIONS.map(async (question) => { + await prisma.questionsQuestion.create({ + data: { + ...question, + encounters: { + create: { + ...question.encounters!.create, + companyId: firstCompany.id, + } as any, + }, + }, + }); + }), + ]); + + console.log('Creating answers to coding questions...'); + const codingQuestions = await prisma.questionsQuestion.findMany({ + where: { + questionType: 'CODING', + }, + }); + await Promise.all( + codingQuestions.map(async (question) => { + const answers = Array.from( + { length: Math.floor(Math.random() * 5) }, + () => ({ + content: generateRandomCodingAnswer(), + userId: users[Math.floor(Math.random() * users.length)].id, + questionId: question.id, + }), + ); + + await prisma.questionsAnswer.createMany({ + data: answers, + }); + }), + ); + + console.log('Creating answers to behavioral questions...'); + const behavioralQuestions = await prisma.questionsQuestion.findMany({ + where: { + questionType: 'BEHAVIORAL', + }, + }); + + await Promise.all( + behavioralQuestions.map(async (question) => { + const answers = Array.from( + { length: Math.floor(Math.random() * 5) }, + + () => ({ + content: generateRandomBehavioralAnswer(), + userId: users[Math.floor(Math.random() * users.length)].id, + questionId: question.id, + }), + ); + + await prisma.questionsAnswer.createMany({ + data: answers, + }); + }), + ); + + console.log('Seeding completed.'); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/portal/src/components/questions/ContributeQuestionCard.tsx b/apps/portal/src/components/questions/ContributeQuestionCard.tsx index 02f496aa..f4256e8d 100644 --- a/apps/portal/src/components/questions/ContributeQuestionCard.tsx +++ b/apps/portal/src/components/questions/ContributeQuestionCard.tsx @@ -30,7 +30,7 @@ export default function ContributeQuestionCard({ return (
+ - ); } diff --git a/apps/portal/src/components/questions/typeahead/RoleTypeahead.tsx b/apps/portal/src/components/questions/typeahead/RoleTypeahead.tsx index d40d6678..60dd3ffa 100644 --- a/apps/portal/src/components/questions/typeahead/RoleTypeahead.tsx +++ b/apps/portal/src/components/questions/typeahead/RoleTypeahead.tsx @@ -1,13 +1,21 @@ -import { ROLES } from '~/utils/questions/constants'; +import { JobTitleLabels } from '~/components/shared/JobTitles'; import type { ExpandedTypeaheadProps } from './ExpandedTypeahead'; import ExpandedTypeahead from './ExpandedTypeahead'; +import type { FilterChoices } from '../filter/FilterSection'; export type RoleTypeaheadProps = Omit< ExpandedTypeaheadProps, 'label' | 'onQueryChange' | 'options' >; +const ROLES: FilterChoices = Object.entries(JobTitleLabels).map( + ([slug, label]) => ({ + id: slug, + label, + value: slug, + }), +); export default function RoleTypeahead(props: RoleTypeaheadProps) { return ( { + if (!aggregatedEncounters) { + return aggregatedEncounters; + } + + return relabelQuestionAggregates(aggregatedEncounters); + }, [aggregatedEncounters]); + const utils = trpc.useContext(); const { data: commentData } = trpc.useInfiniteQuery( @@ -175,11 +184,11 @@ export default function QuestionPage() {
acc + page.data.length, + (acc, page) => acc + (page.data.length as number), 0, ); }, [questionsQueryData]); @@ -273,7 +275,7 @@ export default function QuestionsBrowsePage() { return selectedRoles.map((role) => ({ checked: true, id: role, - label: role, + label: JobTitleLabels[role as keyof typeof JobTitleLabels], value: role, })); }, [selectedRoles]); @@ -369,7 +371,7 @@ export default function QuestionsBrowsePage() { setSelectedRoles([...selectedRoles, option.value]); } else { setSelectedRoles( - selectedCompanies.filter((role) => role !== option.value), + selectedRoles.filter((role) => role !== option.value), ); } }} @@ -443,20 +445,20 @@ export default function QuestionsBrowsePage() {
-
-
- { - createQuestion({ - companyId: data.company, - content: data.questionContent, - location: data.location, - questionType: data.questionType, - role: data.role, - seenAt: data.date, - }); - }} - /> +
+ { + createQuestion({ + companyId: data.company, + content: data.questionContent, + location: data.location, + questionType: data.questionType, + role: data.role, + seenAt: data.date, + }); + }} + /> +
-
- {(questionsQueryData?.pages ?? []).flatMap( - ({ data: questions }) => - questions.map((question) => ( +
+
+ {(questionsQueryData?.pages ?? []).flatMap( + ({ data: questions }) => + questions.map((question) => { + const { companyCounts, locationCounts, roleCounts } = + relabelQuestionAggregates( + question.aggregatedQuestionEncounters, + ); + + return ( - )), - )} -
+ ); + }), + )} +
diff --git a/apps/portal/src/pages/questions/lists.tsx b/apps/portal/src/pages/questions/lists.tsx index ea4009f8..cbf1b276 100644 --- a/apps/portal/src/pages/questions/lists.tsx +++ b/apps/portal/src/pages/questions/lists.tsx @@ -15,6 +15,7 @@ import DeleteListDialog from '~/components/questions/DeleteListDialog'; import { Button } from '~/../../../packages/ui/dist'; import { APP_TITLE } from '~/utils/questions/constants'; import createSlug from '~/utils/questions/createSlug'; +import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates'; import { trpc } from '~/utils/trpc'; export default function ListPage() { @@ -172,37 +173,38 @@ export default function ListPage() { {lists?.[selectedListIndex] && (
{lists[selectedListIndex].questionEntries.map( - ({ question, id: entryId }) => ( - { - deleteQuestionEntry({ id: entryId }); - }} - /> - ), + ({ question, id: entryId }) => { + const { companyCounts, locationCounts, roleCounts } = + relabelQuestionAggregates( + question.aggregatedQuestionEncounters, + ); + + return ( + { + deleteQuestionEntry({ id: entryId }); + }} + /> + ); + }, )} {lists[selectedListIndex].questionEntries?.length === 0 && (
diff --git a/apps/portal/src/server/router/questions-list-router.ts b/apps/portal/src/server/router/questions-list-router.ts deleted file mode 100644 index 3187c914..00000000 --- a/apps/portal/src/server/router/questions-list-router.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { z } from 'zod'; -import { TRPCError } from '@trpc/server'; - -import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters'; - -import { createProtectedRouter } from './context'; - -export const questionsListRouter = createProtectedRouter() - .query('getListsByUser', { - async resolve({ ctx }) { - const userId = ctx.session?.user?.id; - - // TODO: Optimize by not returning question entries - const questionsLists = await ctx.prisma.questionsList.findMany({ - include: { - questionEntries: { - include: { - question: { - include: { - _count: { - select: { - answers: true, - comments: true, - }, - }, - encounters: { - select: { - company: true, - location: true, - role: true, - seenAt: true, - }, - }, - user: { - select: { - name: true, - }, - }, - votes: true, - }, - }, - }, - }, - }, - orderBy: { - createdAt: 'asc', - }, - where: { - userId, - }, - }); - - const lists = questionsLists.map((list) => ({ - ...list, - questionEntries: list.questionEntries.map((entry) => ({ - ...entry, - question: createQuestionWithAggregateData(entry.question), - })), - })); - - return lists; - }, - }) - .query('getListById', { - input: z.object({ - listId: z.string(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - const { listId } = input; - - const questionList = await ctx.prisma.questionsList.findFirst({ - include: { - questionEntries: { - include: { - question: { - include: { - _count: { - select: { - answers: true, - comments: true, - }, - }, - encounters: { - select: { - company: true, - location: true, - role: true, - seenAt: true, - }, - }, - user: { - select: { - name: true, - }, - }, - votes: true, - }, - }, - }, - }, - }, - orderBy: { - createdAt: 'asc', - }, - where: { - id: listId, - userId, - }, - }); - - if (!questionList) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Question list not found', - }); - } - - return { - ...questionList, - questionEntries: questionList.questionEntries.map((questionEntry) => ({ - ...questionEntry, - question: createQuestionWithAggregateData(questionEntry.question), - })), - }; - }, - }) - .mutation('create', { - input: z.object({ - name: z.string(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - - const { name } = input; - - return await ctx.prisma.questionsList.create({ - data: { - name, - userId, - }, - }); - }, - }) - .mutation('update', { - input: z.object({ - id: z.string(), - name: z.string().optional(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - const { name, id } = input; - - const listToUpdate = await ctx.prisma.questionsList.findUnique({ - where: { - id: input.id, - }, - }); - - if (listToUpdate?.id !== userId) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User have no authorization to record.', - }); - } - - return await ctx.prisma.questionsList.update({ - data: { - name, - }, - where: { - id, - }, - }); - }, - }) - .mutation('delete', { - input: z.object({ - id: z.string(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - - const listToDelete = await ctx.prisma.questionsList.findUnique({ - where: { - id: input.id, - }, - }); - - if (listToDelete?.userId !== userId) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User have no authorization to record.', - }); - } - - return await ctx.prisma.questionsList.delete({ - where: { - id: input.id, - }, - }); - }, - }) - .mutation('createQuestionEntry', { - input: z.object({ - listId: z.string(), - questionId: z.string(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - - const listToAugment = await ctx.prisma.questionsList.findUnique({ - where: { - id: input.listId, - }, - }); - - if (listToAugment?.userId !== userId) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User have no authorization to record.', - }); - } - - const { questionId, listId } = input; - - return await ctx.prisma.questionsListQuestionEntry.create({ - data: { - listId, - questionId, - }, - }); - }, - }) - .mutation('deleteQuestionEntry', { - input: z.object({ - id: z.string(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - - const entryToDelete = - await ctx.prisma.questionsListQuestionEntry.findUnique({ - where: { - id: input.id, - }, - }); - - if (entryToDelete === null) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Entry not found.', - }); - } - - const listToAugment = await ctx.prisma.questionsList.findUnique({ - where: { - id: entryToDelete.listId, - }, - }); - - if (listToAugment?.userId !== userId) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User have no authorization to record.', - }); - } - - return await ctx.prisma.questionsListQuestionEntry.delete({ - where: { - id: input.id, - }, - }); - }, - }); diff --git a/apps/portal/src/server/router/questions-question-router.ts b/apps/portal/src/server/router/questions-question-router.ts deleted file mode 100644 index b0c02981..00000000 --- a/apps/portal/src/server/router/questions-question-router.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { z } from 'zod'; -import { QuestionsQuestionType, Vote } from '@prisma/client'; -import { TRPCError } from '@trpc/server'; - -import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters'; - -import { createProtectedRouter } from './context'; - -import { SortOrder, SortType } from '~/types/questions.d'; - -export const questionsQuestionRouter = createProtectedRouter() - .query('getQuestionsByFilter', { - input: z.object({ - companyNames: z.string().array(), - cursor: z - .object({ - idCursor: z.string().optional(), - lastSeenCursor: z.date().nullish().optional(), - upvoteCursor: z.number().optional(), - }) - .nullish(), - endDate: z.date().default(new Date()), - limit: z.number().min(1).default(50), - locations: z.string().array(), - questionTypes: z.nativeEnum(QuestionsQuestionType).array(), - roles: z.string().array(), - sortOrder: z.nativeEnum(SortOrder), - sortType: z.nativeEnum(SortType), - startDate: z.date().optional(), - }), - async resolve({ ctx, input }) { - const { cursor } = input; - - const sortCondition = - input.sortType === SortType.TOP - ? [ - { - upvotes: input.sortOrder, - }, - { - id: input.sortOrder, - }, - ] - : [ - { - lastSeenAt: input.sortOrder, - }, - { - id: input.sortOrder, - }, - ]; - - const questionsData = await ctx.prisma.questionsQuestion.findMany({ - cursor: - cursor !== undefined - ? { - id: cursor ? cursor!.idCursor : undefined, - } - : undefined, - include: { - _count: { - select: { - answers: true, - comments: true, - }, - }, - encounters: { - select: { - company: true, - location: true, - role: true, - seenAt: true, - }, - }, - user: { - select: { - name: true, - }, - }, - votes: true, - }, - orderBy: sortCondition, - take: input.limit + 1, - where: { - ...(input.questionTypes.length > 0 - ? { - questionType: { - in: input.questionTypes, - }, - } - : {}), - encounters: { - some: { - seenAt: { - gte: input.startDate, - lte: input.endDate, - }, - ...(input.companyNames.length > 0 - ? { - company: { - name: { - in: input.companyNames, - }, - }, - } - : {}), - ...(input.locations.length > 0 - ? { - location: { - in: input.locations, - }, - } - : {}), - ...(input.roles.length > 0 - ? { - role: { - in: input.roles, - }, - } - : {}), - }, - }, - }, - }); - - const processedQuestionsData = questionsData.map( - createQuestionWithAggregateData, - ); - - let nextCursor: typeof cursor | undefined = undefined; - - if (questionsData.length > input.limit) { - const nextItem = questionsData.pop()!; - processedQuestionsData.pop(); - - const nextIdCursor: string | undefined = nextItem.id; - const nextLastSeenCursor = - input.sortType === SortType.NEW ? nextItem.lastSeenAt : undefined; - const nextUpvoteCursor = - input.sortType === SortType.TOP ? nextItem.upvotes : undefined; - - nextCursor = { - idCursor: nextIdCursor, - lastSeenCursor: nextLastSeenCursor, - upvoteCursor: nextUpvoteCursor, - }; - } - - return { - data: processedQuestionsData, - nextCursor, - }; - }, - }) - .query('getQuestionById', { - input: z.object({ - id: z.string(), - }), - async resolve({ ctx, input }) { - const questionData = await ctx.prisma.questionsQuestion.findUnique({ - include: { - _count: { - select: { - answers: true, - comments: true, - }, - }, - encounters: { - select: { - company: true, - location: true, - role: true, - seenAt: true, - }, - }, - user: { - select: { - name: true, - }, - }, - votes: true, - }, - where: { - id: input.id, - }, - }); - if (!questionData) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Question not found', - }); - } - - return createQuestionWithAggregateData(questionData); - }, - }) - .mutation('create', { - input: z.object({ - companyId: z.string(), - content: z.string(), - location: z.string(), - questionType: z.nativeEnum(QuestionsQuestionType), - role: z.string(), - seenAt: z.date(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - - return await ctx.prisma.questionsQuestion.create({ - data: { - content: input.content, - encounters: { - create: { - company: { - connect: { - id: input.companyId, - }, - }, - location: input.location, - role: input.role, - seenAt: input.seenAt, - user: { - connect: { - id: userId, - }, - }, - }, - }, - lastSeenAt: input.seenAt, - questionType: input.questionType, - userId, - }, - }); - }, - }) - .mutation('update', { - input: z.object({ - content: z.string().optional(), - id: z.string(), - questionType: z.nativeEnum(QuestionsQuestionType).optional(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - - const questionToUpdate = await ctx.prisma.questionsQuestion.findUnique({ - where: { - id: input.id, - }, - }); - - if (questionToUpdate?.id !== userId) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User have no authorization to record.', - // Optional: pass the original error to retain stack trace - }); - } - - const { content, questionType } = input; - - return await ctx.prisma.questionsQuestion.update({ - data: { - content, - questionType, - }, - where: { - id: input.id, - }, - }); - }, - }) - .mutation('delete', { - input: z.object({ - id: z.string(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - - const questionToDelete = await ctx.prisma.questionsQuestion.findUnique({ - where: { - id: input.id, - }, - }); - - if (questionToDelete?.id !== userId) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User have no authorization to record.', - // Optional: pass the original error to retain stack trace - }); - } - - return await ctx.prisma.questionsQuestion.delete({ - where: { - id: input.id, - }, - }); - }, - }) - .query('getVote', { - input: z.object({ - questionId: z.string(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - const { questionId } = input; - - return await ctx.prisma.questionsQuestionVote.findUnique({ - where: { - questionId_userId: { questionId, userId }, - }, - }); - }, - }) - .mutation('createVote', { - input: z.object({ - questionId: z.string(), - vote: z.nativeEnum(Vote), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - const { questionId, vote } = input; - - const incrementValue = vote === Vote.UPVOTE ? 1 : -1; - - const [questionVote] = await ctx.prisma.$transaction([ - ctx.prisma.questionsQuestionVote.create({ - data: { - questionId, - userId, - vote, - }, - }), - ctx.prisma.questionsQuestion.update({ - data: { - upvotes: { - increment: incrementValue, - }, - }, - where: { - id: questionId, - }, - }), - ]); - return questionVote; - }, - }) - .mutation('updateVote', { - input: z.object({ - id: z.string(), - vote: z.nativeEnum(Vote), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - const { id, vote } = input; - - const voteToUpdate = await ctx.prisma.questionsQuestionVote.findUnique({ - where: { - id: input.id, - }, - }); - - if (voteToUpdate?.userId !== userId) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User have no authorization to record.', - }); - } - - const incrementValue = vote === Vote.UPVOTE ? 2 : -2; - - const [questionVote] = await ctx.prisma.$transaction([ - ctx.prisma.questionsQuestionVote.update({ - data: { - vote, - }, - where: { - id, - }, - }), - ctx.prisma.questionsQuestion.update({ - data: { - upvotes: { - increment: incrementValue, - }, - }, - where: { - id: voteToUpdate.questionId, - }, - }), - ]); - - return questionVote; - }, - }) - .mutation('deleteVote', { - input: z.object({ - id: z.string(), - }), - async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; - - const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({ - where: { - id: input.id, - }, - }); - - if (voteToDelete?.userId !== userId) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User have no authorization to record.', - }); - } - - const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1; - - const [questionVote] = await ctx.prisma.$transaction([ - ctx.prisma.questionsQuestionVote.delete({ - where: { - id: input.id, - }, - }), - ctx.prisma.questionsQuestion.update({ - data: { - upvotes: { - increment: incrementValue, - }, - }, - where: { - id: voteToDelete.questionId, - }, - }), - ]); - return questionVote; - }, - }); diff --git a/apps/portal/src/server/router/questions/questions-answer-comment-user-router.ts b/apps/portal/src/server/router/questions/questions-answer-comment-user-router.ts index e9d77a73..6d00ae5b 100644 --- a/apps/portal/src/server/router/questions/questions-answer-comment-user-router.ts +++ b/apps/portal/src/server/router/questions/questions-answer-comment-user-router.ts @@ -127,7 +127,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() where: { answerCommentId_userId: { answerCommentId, userId }, }, - }) + }); if (vote === null) { const createdVote = await tx.questionsAnswerCommentVote.create({ @@ -149,7 +149,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() }, }); - return createdVote + return createdVote; } if (vote!.userId !== userId) { @@ -164,18 +164,15 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() } if (vote.vote === Vote.DOWNVOTE) { - tx.questionsAnswerCommentVote.delete({ - where: { - id: vote.id, - }, - }); - - const createdVote = await tx.questionsAnswerCommentVote.create({ + const updatedVote = await tx.questionsAnswerCommentVote.update({ data: { answerCommentId, userId, vote: Vote.UPVOTE, }, + where: { + id: vote.id, + }, }); await tx.questionsAnswerComment.update({ @@ -189,7 +186,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() }, }); - return createdVote + return updatedVote; } }); }, @@ -221,7 +218,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() where: { answerCommentId_userId: { answerCommentId, userId }, }, - }) + }); if (vote === null) { const createdVote = await tx.questionsAnswerCommentVote.create({ @@ -243,7 +240,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() }, }); - return createdVote + return createdVote; } if (vote!.userId !== userId) { @@ -258,18 +255,15 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() } if (vote.vote === Vote.UPVOTE) { - tx.questionsAnswerCommentVote.delete({ - where: { - id: vote.id, - }, - }); - - const createdVote = await tx.questionsAnswerCommentVote.create({ + const updatedVote = await tx.questionsAnswerCommentVote.update({ data: { answerCommentId, userId, vote: Vote.DOWNVOTE, }, + where: { + id: vote.id, + }, }); await tx.questionsAnswerComment.update({ @@ -283,7 +277,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() }, }); - return createdVote + return updatedVote; } }); }, @@ -315,7 +309,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() where: { answerCommentId_userId: { answerCommentId, userId }, }, - }) + }); if (voteToDelete === null) { return null; @@ -330,7 +324,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() const incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1; - tx.questionsAnswerCommentVote.delete({ + await tx.questionsAnswerCommentVote.delete({ where: { id: voteToDelete.id, }, @@ -350,4 +344,4 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter() return voteToDelete; }); }, - }); \ No newline at end of file + }); diff --git a/apps/portal/src/server/router/questions/questions-answer-user-router.ts b/apps/portal/src/server/router/questions/questions-answer-user-router.ts index 6418d500..27c053be 100644 --- a/apps/portal/src/server/router/questions/questions-answer-user-router.ts +++ b/apps/portal/src/server/router/questions/questions-answer-user-router.ts @@ -107,12 +107,11 @@ export const questionsAnswerUserRouter = createProtectedRouter() const { answerId } = input; return await ctx.prisma.$transaction(async (tx) => { - const answerToUpdate = - await tx.questionsAnswer.findUnique({ - where: { - id: answerId, - }, - }); + const answerToUpdate = await tx.questionsAnswer.findUnique({ + where: { + id: answerId, + }, + }); if (answerToUpdate === null) { throw new TRPCError({ @@ -125,7 +124,7 @@ export const questionsAnswerUserRouter = createProtectedRouter() where: { answerId_userId: { answerId, userId }, }, - }) + }); if (vote === null) { const createdVote = await tx.questionsAnswerVote.create({ @@ -147,7 +146,7 @@ export const questionsAnswerUserRouter = createProtectedRouter() }, }); - return createdVote + return createdVote; } if (vote!.userId !== userId) { @@ -162,18 +161,15 @@ export const questionsAnswerUserRouter = createProtectedRouter() } if (vote.vote === Vote.DOWNVOTE) { - tx.questionsAnswerVote.delete({ - where: { - id: vote.id, - }, - }); - - const createdVote = await tx.questionsAnswerVote.create({ + const updatedVote = await tx.questionsAnswerVote.update({ data: { answerId, userId, vote: Vote.UPVOTE, }, + where: { + id: vote.id, + }, }); await tx.questionsAnswer.update({ @@ -187,7 +183,7 @@ export const questionsAnswerUserRouter = createProtectedRouter() }, }); - return createdVote + return updatedVote; } }); }, @@ -201,12 +197,11 @@ export const questionsAnswerUserRouter = createProtectedRouter() const { answerId } = input; return await ctx.prisma.$transaction(async (tx) => { - const answerToUpdate = - await tx.questionsAnswer.findUnique({ - where: { - id: answerId, - }, - }); + const answerToUpdate = await tx.questionsAnswer.findUnique({ + where: { + id: answerId, + }, + }); if (answerToUpdate === null) { throw new TRPCError({ @@ -219,7 +214,7 @@ export const questionsAnswerUserRouter = createProtectedRouter() where: { answerId_userId: { answerId, userId }, }, - }) + }); if (vote === null) { const createdVote = await tx.questionsAnswerVote.create({ @@ -241,7 +236,7 @@ export const questionsAnswerUserRouter = createProtectedRouter() }, }); - return createdVote + return createdVote; } if (vote!.userId !== userId) { @@ -256,18 +251,15 @@ export const questionsAnswerUserRouter = createProtectedRouter() } if (vote.vote === Vote.UPVOTE) { - tx.questionsAnswerVote.delete({ - where: { - id: vote.id, - }, - }); - - const createdVote = await tx.questionsAnswerVote.create({ + const updatedVote = await tx.questionsAnswerVote.update({ data: { answerId, userId, vote: Vote.DOWNVOTE, }, + where: { + id: vote.id, + }, }); await tx.questionsAnswer.update({ @@ -281,7 +273,7 @@ export const questionsAnswerUserRouter = createProtectedRouter() }, }); - return createdVote + return updatedVote; } }); }, @@ -295,12 +287,11 @@ export const questionsAnswerUserRouter = createProtectedRouter() const { answerId } = input; return await ctx.prisma.$transaction(async (tx) => { - const answerToUpdate = - await tx.questionsAnswer.findUnique({ - where: { - id: answerId, - }, - }); + const answerToUpdate = await tx.questionsAnswer.findUnique({ + where: { + id: answerId, + }, + }); if (answerToUpdate === null) { throw new TRPCError({ @@ -313,7 +304,7 @@ export const questionsAnswerUserRouter = createProtectedRouter() where: { answerId_userId: { answerId, userId }, }, - }) + }); if (voteToDelete === null) { return null; @@ -328,7 +319,7 @@ export const questionsAnswerUserRouter = createProtectedRouter() const incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1; - tx.questionsAnswerVote.delete({ + await tx.questionsAnswerVote.delete({ where: { id: voteToDelete.id, }, diff --git a/apps/portal/src/server/router/questions/questions-question-comment-user-router.ts b/apps/portal/src/server/router/questions/questions-question-comment-user-router.ts index 5066197e..f2b9afb9 100644 --- a/apps/portal/src/server/router/questions/questions-question-comment-user-router.ts +++ b/apps/portal/src/server/router/questions/questions-question-comment-user-router.ts @@ -128,7 +128,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter() where: { questionCommentId_userId: { questionCommentId, userId }, }, - }) + }); if (vote === null) { const createdVote = await tx.questionsQuestionCommentVote.create({ @@ -150,7 +150,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter() }, }); - return createdVote + return createdVote; } if (vote!.userId !== userId) { @@ -165,18 +165,15 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter() } if (vote.vote === Vote.DOWNVOTE) { - tx.questionsQuestionCommentVote.delete({ - where: { - id: vote.id, - }, - }); - - const createdVote = await tx.questionsQuestionCommentVote.create({ + const updatedVote = await tx.questionsQuestionCommentVote.update({ data: { questionCommentId, userId, vote: Vote.UPVOTE, }, + where: { + id: vote.id, + }, }); await tx.questionsQuestionComment.update({ @@ -190,7 +187,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter() }, }); - return createdVote + return updatedVote; } }); }, @@ -222,7 +219,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter() where: { questionCommentId_userId: { questionCommentId, userId }, }, - }) + }); if (vote === null) { const createdVote = await tx.questionsQuestionCommentVote.create({ @@ -244,7 +241,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter() }, }); - return createdVote + return createdVote; } if (vote!.userId !== userId) { @@ -284,7 +281,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter() }, }); - return createdVote + return createdVote; } }); }, @@ -316,7 +313,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter() where: { questionCommentId_userId: { questionCommentId, userId }, }, - }) + }); if (voteToDelete === null) { return null; @@ -331,7 +328,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter() const incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1; - tx.questionsQuestionCommentVote.delete({ + await tx.questionsQuestionCommentVote.delete({ where: { id: voteToDelete.id, }, diff --git a/apps/portal/src/server/router/questions/questions-question-router.ts b/apps/portal/src/server/router/questions/questions-question-router.ts index 93d95d09..5208a3a3 100644 --- a/apps/portal/src/server/router/questions/questions-question-router.ts +++ b/apps/portal/src/server/router/questions/questions-question-router.ts @@ -182,4 +182,64 @@ export const questionsQuestionRouter = createRouter() return createQuestionWithAggregateData(questionData); }, + }) + .query('getRelatedQuestions', { + input: z.object({ + content: z.string(), + }), + async resolve({ ctx, input }) { + const escapeChars = /[()|&:*!]/g; + + const query = + input.content + .replace(escapeChars, " ") + .trim() + .split(/\s+/) + .join(" | "); + + const relatedQuestionsId : Array<{id:string}> = await ctx.prisma.$queryRaw` + SELECT id FROM "QuestionsQuestion" + WHERE + to_tsvector("content") @@ to_tsquery('english', ${query}) + ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC; + `; + + const relatedQuestionsIdArray = relatedQuestionsId.map(current => current.id); + + const relatedQuestionsData = await ctx.prisma.questionsQuestion.findMany({ + include: { + _count: { + select: { + answers: true, + comments: true, + }, + }, + encounters: { + select: { + company: true, + location: true, + role: true, + seenAt: true, + }, + }, + user: { + select: { + name: true, + }, + }, + votes: true, + }, + where: { + id : { + in : relatedQuestionsIdArray, + } + }, + }); + + const processedQuestionsData = relatedQuestionsData.map( + createQuestionWithAggregateData, + ); + + return processedQuestionsData; + } }); diff --git a/apps/portal/src/server/router/questions/questions-question-user-router.ts b/apps/portal/src/server/router/questions/questions-question-user-router.ts index 25a53d0b..caf7d409 100644 --- a/apps/portal/src/server/router/questions/questions-question-user-router.ts +++ b/apps/portal/src/server/router/questions/questions-question-user-router.ts @@ -132,12 +132,11 @@ export const questionsQuestionUserRouter = createProtectedRouter() const { questionId } = input; return await ctx.prisma.$transaction(async (tx) => { - const questionToUpdate = - await tx.questionsQuestion.findUnique({ - where: { - id: questionId, - }, - }); + const questionToUpdate = await tx.questionsQuestion.findUnique({ + where: { + id: questionId, + }, + }); if (questionToUpdate === null) { throw new TRPCError({ @@ -150,7 +149,7 @@ export const questionsQuestionUserRouter = createProtectedRouter() where: { questionId_userId: { questionId, userId }, }, - }) + }); if (vote === null) { const createdVote = await tx.questionsQuestionVote.create({ @@ -172,7 +171,7 @@ export const questionsQuestionUserRouter = createProtectedRouter() }, }); - return createdVote + return createdVote; } if (vote!.userId !== userId) { @@ -187,18 +186,15 @@ export const questionsQuestionUserRouter = createProtectedRouter() } if (vote.vote === Vote.DOWNVOTE) { - tx.questionsQuestionVote.delete({ - where: { - id: vote.id, - }, - }); - - const createdVote = await tx.questionsQuestionVote.create({ + const updatedVote = await tx.questionsQuestionVote.update({ data: { questionId, userId, vote: Vote.UPVOTE, }, + where: { + id: vote.id, + }, }); await tx.questionsQuestion.update({ @@ -212,7 +208,7 @@ export const questionsQuestionUserRouter = createProtectedRouter() }, }); - return createdVote + return updatedVote; } }); }, @@ -226,12 +222,11 @@ export const questionsQuestionUserRouter = createProtectedRouter() const { questionId } = input; return await ctx.prisma.$transaction(async (tx) => { - const questionToUpdate = - await tx.questionsQuestion.findUnique({ - where: { - id: questionId, - }, - }); + const questionToUpdate = await tx.questionsQuestion.findUnique({ + where: { + id: questionId, + }, + }); if (questionToUpdate === null) { throw new TRPCError({ @@ -244,7 +239,7 @@ export const questionsQuestionUserRouter = createProtectedRouter() where: { questionId_userId: { questionId, userId }, }, - }) + }); if (vote === null) { const createdVote = await tx.questionsQuestionVote.create({ @@ -266,7 +261,7 @@ export const questionsQuestionUserRouter = createProtectedRouter() }, }); - return createdVote + return createdVote; } if (vote!.userId !== userId) { @@ -276,23 +271,20 @@ export const questionsQuestionUserRouter = createProtectedRouter() }); } - if (vote!.vote === Vote.DOWNVOTE) { + if (vote.vote === Vote.DOWNVOTE) { return vote; } if (vote.vote === Vote.UPVOTE) { - tx.questionsQuestionVote.delete({ - where: { - id: vote.id, - }, - }); - - const createdVote = await tx.questionsQuestionVote.create({ + const updatedVote = await tx.questionsQuestionVote.update({ data: { questionId, userId, vote: Vote.DOWNVOTE, }, + where: { + id: vote.id, + }, }); await tx.questionsQuestion.update({ @@ -306,7 +298,7 @@ export const questionsQuestionUserRouter = createProtectedRouter() }, }); - return createdVote + return updatedVote; } }); }, @@ -320,12 +312,11 @@ export const questionsQuestionUserRouter = createProtectedRouter() const { questionId } = input; return await ctx.prisma.$transaction(async (tx) => { - const questionToUpdate = - await tx.questionsQuestion.findUnique({ - where: { - id: questionId, - }, - }); + const questionToUpdate = await tx.questionsQuestion.findUnique({ + where: { + id: questionId, + }, + }); if (questionToUpdate === null) { throw new TRPCError({ @@ -338,7 +329,7 @@ export const questionsQuestionUserRouter = createProtectedRouter() where: { questionId_userId: { questionId, userId }, }, - }) + }); if (voteToDelete === null) { return null; @@ -353,7 +344,7 @@ export const questionsQuestionUserRouter = createProtectedRouter() const incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1; - tx.questionsQuestionVote.delete({ + await tx.questionsQuestionVote.delete({ where: { id: voteToDelete.id, }, diff --git a/apps/portal/src/utils/questions/relabelQuestionAggregates.ts b/apps/portal/src/utils/questions/relabelQuestionAggregates.ts new file mode 100644 index 00000000..50a2a5dd --- /dev/null +++ b/apps/portal/src/utils/questions/relabelQuestionAggregates.ts @@ -0,0 +1,26 @@ +import { JobTitleLabels } from '~/components/shared/JobTitles'; + +import type { AggregatedQuestionEncounter } from '~/types/questions'; + +export default function relabelQuestionAggregates({ + locationCounts, + companyCounts, + roleCounts, + latestSeenAt, +}: AggregatedQuestionEncounter) { + const newRoleCounts = Object.fromEntries( + Object.entries(roleCounts).map(([roleId, count]) => [ + JobTitleLabels[roleId as keyof typeof JobTitleLabels], + count, + ]), + ); + + const relabeledAggregate: AggregatedQuestionEncounter = { + companyCounts, + latestSeenAt, + locationCounts, + roleCounts: newRoleCounts, + }; + + return relabeledAggregate; +} diff --git a/apps/portal/src/utils/questions/useVote.ts b/apps/portal/src/utils/questions/useVote.ts index dcac2164..e35a608a 100644 --- a/apps/portal/src/utils/questions/useVote.ts +++ b/apps/portal/src/utils/questions/useVote.ts @@ -5,9 +5,9 @@ import type { Vote } from '@prisma/client'; import { trpc } from '../trpc'; type UseVoteOptions = { - createVote: (opts: { vote: Vote }) => void; - deleteVote: (opts: { id: string }) => void; - updateVote: (opts: BackendVote) => void; + setDownVote: () => void; + setNoVote: () => void; + setUpVote: () => void; }; type BackendVote = { @@ -19,47 +19,23 @@ const createVoteCallbacks = ( vote: BackendVote | null, opts: UseVoteOptions, ) => { - const { createVote, updateVote, deleteVote } = opts; + const { setDownVote, setNoVote, setUpVote } = opts; const handleUpvote = () => { // Either upvote or remove upvote - if (vote) { - if (vote.vote === 'DOWNVOTE') { - updateVote({ - id: vote.id, - vote: 'UPVOTE', - }); - } else { - deleteVote({ - id: vote.id, - }); - } - // Update vote to an upvote + if (vote && vote.vote === 'UPVOTE') { + setNoVote(); } else { - createVote({ - vote: 'UPVOTE', - }); + setUpVote(); } }; const handleDownvote = () => { // Either downvote or remove downvote - if (vote) { - if (vote.vote === 'UPVOTE') { - updateVote({ - id: vote.id, - vote: 'DOWNVOTE', - }); - } else { - deleteVote({ - id: vote.id, - }); - } - // Update vote to an upvote + if (vote && vote.vote === 'DOWNVOTE') { + setNoVote(); } else { - createVote({ - vote: 'DOWNVOTE', - }); + setDownVote(); } }; @@ -71,61 +47,61 @@ type QueryKey = Parameters[0][0]; export const useQuestionVote = (id: string) => { return useVote(id, { - create: 'questions.questions.user.createVote', - deleteKey: 'questions.questions.user.deleteVote', idKey: 'questionId', invalidateKeys: [ 'questions.questions.getQuestionsByFilter', 'questions.questions.getQuestionById', ], query: 'questions.questions.user.getVote', - update: 'questions.questions.user.updateVote', + setDownVoteKey: 'questions.questions.user.setDownVote', + setNoVoteKey: 'questions.questions.user.setNoVote', + setUpVoteKey: 'questions.questions.user.setUpVote', }); }; export const useAnswerVote = (id: string) => { return useVote(id, { - create: 'questions.answers.user.createVote', - deleteKey: 'questions.answers.user.deleteVote', idKey: 'answerId', invalidateKeys: [ 'questions.answers.getAnswers', 'questions.answers.getAnswerById', ], query: 'questions.answers.user.getVote', - update: 'questions.answers.user.updateVote', + setDownVoteKey: 'questions.answers.user.setDownVote', + setNoVoteKey: 'questions.answers.user.setNoVote', + setUpVoteKey: 'questions.answers.user.setUpVote', }); }; export const useQuestionCommentVote = (id: string) => { return useVote(id, { - create: 'questions.questions.comments.user.createVote', - deleteKey: 'questions.questions.comments.user.deleteVote', idKey: 'questionCommentId', invalidateKeys: ['questions.questions.comments.getQuestionComments'], query: 'questions.questions.comments.user.getVote', - update: 'questions.questions.comments.user.updateVote', + setDownVoteKey: 'questions.questions.comments.user.setDownVote', + setNoVoteKey: 'questions.questions.comments.user.setNoVote', + setUpVoteKey: 'questions.questions.comments.user.setUpVote', }); }; export const useAnswerCommentVote = (id: string) => { return useVote(id, { - create: 'questions.answers.comments.user.createVote', - deleteKey: 'questions.answers.comments.user.deleteVote', idKey: 'answerCommentId', invalidateKeys: ['questions.answers.comments.getAnswerComments'], query: 'questions.answers.comments.user.getVote', - update: 'questions.answers.comments.user.updateVote', + setDownVoteKey: 'questions.answers.comments.user.setDownVote', + setNoVoteKey: 'questions.answers.comments.user.setNoVote', + setUpVoteKey: 'questions.answers.comments.user.setUpVote', }); }; type VoteProps = { - create: MutationKey; - deleteKey: MutationKey; idKey: string; invalidateKeys: Array; query: VoteQueryKey; - update: MutationKey; + setDownVoteKey: MutationKey; + setNoVoteKey: MutationKey; + setUpVoteKey: MutationKey; }; type UseVoteMutationContext = { @@ -137,7 +113,14 @@ export const useVote = ( id: string, opts: VoteProps, ) => { - const { create, deleteKey, query, update, idKey, invalidateKeys } = opts; + const { + idKey, + invalidateKeys, + query, + setDownVoteKey, + setNoVoteKey, + setUpVoteKey, + } = opts; const utils = trpc.useContext(); const onVoteUpdate = useCallback(() => { @@ -157,8 +140,8 @@ export const useVote = ( const backendVote = data as BackendVote; - const { mutate: createVote } = trpc.useMutation( - create, + const { mutate: setUpVote } = trpc.useMutation( + setUpVoteKey, { onError: (err, variables, context) => { if (context !== undefined) { @@ -185,8 +168,8 @@ export const useVote = ( onSettled: onVoteUpdate, }, ); - const { mutate: updateVote } = trpc.useMutation( - update, + const { mutate: setDownVote } = trpc.useMutation( + setDownVoteKey, { onError: (error, variables, context) => { if (context !== undefined) { @@ -214,8 +197,8 @@ export const useVote = ( }, ); - const { mutate: deleteVote } = trpc.useMutation( - deleteKey, + const { mutate: setNoVote } = trpc.useMutation( + setNoVoteKey, { onError: (err, variables, context) => { if (context !== undefined) { @@ -242,14 +225,21 @@ export const useVote = ( const { handleDownvote, handleUpvote } = createVoteCallbacks( backendVote ?? null, { - createVote: ({ vote }) => { - createVote({ + setDownVote: () => { + setDownVote({ [idKey]: id, - vote, - } as any); + }); + }, + setNoVote: () => { + setNoVote({ + [idKey]: id, + }); + }, + setUpVote: () => { + setUpVote({ + [idKey]: id, + }); }, - deleteVote, - updateVote, }, );