diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts
index c018c3f7..c3046659 100644
--- a/apps/portal/src/server/router/index.ts
+++ b/apps/portal/src/server/router/index.ts
@@ -2,17 +2,21 @@ import superjson from 'superjson';
import { companiesRouter } from './companies-router';
import { createRouter } from './context';
-import { offersRouter } from './offers';
-import { offersProfileRouter } from './offers-profile-router';
+import { offersRouter } from './offers/offers';
+import { offersAnalysisRouter } from './offers/offers-analysis-router';
+import { offersCommentsRouter } from './offers/offers-comments-router';
+import { offersProfileRouter } from './offers/offers-profile-router';
import { protectedExampleRouter } from './protected-example-router';
import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
import { questionsAnswerRouter } from './questions-answer-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router';
import { questionsQuestionRouter } from './questions-question-router';
+import { resumeCommentsRouter } from './resumes/resumes-comments-router';
+import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
+import { resumesCommentsVotesRouter } from './resumes/resumes-comments-votes-router';
+import { resumesCommentsVotesUserRouter } from './resumes/resumes-comments-votes-user-router';
import { resumesRouter } from './resumes/resumes-resume-router';
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
-import { resumeReviewsRouter } from './resumes/resumes-reviews-router';
-import { resumesReviewsUserRouter } from './resumes/resumes-reviews-user-router';
import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
import { todosRouter } from './todos';
import { todosUserRouter } from './todos-user-router';
@@ -28,15 +32,19 @@ export const appRouter = createRouter()
.merge('companies.', companiesRouter)
.merge('resumes.resume.', resumesRouter)
.merge('resumes.resume.user.', resumesResumeUserRouter)
- .merge('resumes.star.user.', resumesStarUserRouter)
- .merge('resumes.reviews.', resumeReviewsRouter)
- .merge('resumes.reviews.user.', resumesReviewsUserRouter)
+ .merge('resumes.resume.', resumesStarUserRouter)
+ .merge('resumes.comments.', resumeCommentsRouter)
+ .merge('resumes.comments.user.', resumesCommentsUserRouter)
+ .merge('resumes.comments.votes.', resumesCommentsVotesRouter)
+ .merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.', questionsQuestionRouter)
.merge('offers.', offersRouter)
- .merge('offers.profile.', offersProfileRouter);
+ .merge('offers.profile.', offersProfileRouter)
+ .merge('offers.analysis.', offersAnalysisRouter)
+ .merge('offers.comments.', offersCommentsRouter);
// Export type definition of API
export type AppRouter = typeof appRouter;
diff --git a/apps/portal/src/server/router/offers-profile-router.ts b/apps/portal/src/server/router/offers-profile-router.ts
deleted file mode 100644
index 3434d854..00000000
--- a/apps/portal/src/server/router/offers-profile-router.ts
+++ /dev/null
@@ -1,269 +0,0 @@
-import crypto, { randomUUID } from "crypto";
-import { z } from "zod";
-import { Prisma } from "@prisma/client";
-
-import { createRouter } from "./context";
-
-const valuation = z.object({
- currency: z.string(),
- value: z.number(),
-})
-
-// TODO: handle both full time and intern
-const offer = z.object({
- comments: z.string(),
- companyId: z.string(),
- job: z.object({
- base: valuation.optional(), // Full time
- bonus: valuation.optional(), // Full time
- internshipCycle: z.string().optional(), // Intern
- level: z.string().optional(), // Full time
- monthlySalary: valuation.optional(), // Intern
- specialization: z.string(),
- startYear: z.number().optional(), // Intern
- stocks: valuation.optional(), // Full time
- title: z.string(),
- totalCompensation: valuation.optional(), // Full time
- }),
- jobType: z.string(),
- location: z.string(),
- monthYearReceived: z.date(),
- negotiationStrategy: z.string(),
-})
-
-const experience = z.object({
- companyId: z.string().optional(),
- durationInMonths: z.number().optional(),
- jobType: z.string().optional(),
- level: z.string().optional(),
- monthlySalary: valuation.optional(),
- specialization: z.string().optional(),
- title: z.string().optional(),
- totalCompensation: valuation.optional(),
-})
-
-const education = z.object({
- endDate: z.date().optional(),
- field: z.string().optional(),
- school: z.string().optional(),
- startDate: z.date().optional(),
- type: z.string().optional(),
-})
-
-export const offersProfileRouter = createRouter().mutation(
- 'create',
- {
- input: z.object({
- background: z.object({
- educations: z.array(education),
- experiences: z.array(experience),
- specificYoes: z.array(z.object({
- domain: z.string(),
- yoe: z.number()
- })),
- totalYoe: z.number().optional(),
- }),
- offers: z.array(offer)
- }),
- async resolve({ ctx, input }) {
-
- // TODO: add more
- const token = crypto
- .createHash("sha256")
- .update(Date.now().toString())
- .digest("hex");
-
- const profile = await ctx.prisma.offersProfile.create({
- data: {
- background: {
- create: {
- educations: {
- create:
- input.background.educations.map((x) => ({
- endDate: x.endDate,
- field: x.field,
- school: x.school,
- startDate: x.startDate,
- type: x.type
- }))
- },
- experiences: {
- create:
- input.background.experiences.map((x) => {
- if (x.jobType === "FULLTIME" && x.totalCompensation?.currency !== undefined && x.totalCompensation.value !== undefined) {
- return {
- company: {
- connect: {
- id: x.companyId
- }
- },
- durationInMonths: x.durationInMonths,
- jobType: x.jobType,
- level: x.level,
- specialization: x.specialization,
- title: x.title,
- totalCompensation: {
- create: {
- currency: x.totalCompensation?.currency,
- value: x.totalCompensation?.value,
- }
- },
- }
- }
- if (x.jobType === "INTERN" && x.monthlySalary?.currency !== undefined && x.monthlySalary.value !== undefined) {
- return {
- company: {
- connect: {
- id: x.companyId
- }
- },
- durationInMonths: x.durationInMonths,
- jobType: x.jobType,
- monthlySalary: {
- create: {
- currency: x.monthlySalary?.currency,
- value: x.monthlySalary?.value
- }
- },
- specialization: x.specialization,
- title: x.title,
- }
- }
-
- throw Prisma.PrismaClientKnownRequestError
-
- })
- },
- specificYoes: {
- create:
- input.background.specificYoes.map((x) => ({
- domain: x.domain,
- yoe: x.yoe
- }))
- },
- totalYoe: input.background.totalYoe,
- }
- },
- editToken: token,
- offers: {
- create:
- input.offers.map((x) => {
- if (x.jobType === "INTERN" && x.job.internshipCycle !== undefined && x.job.monthlySalary?.currency !== undefined && x.job.monthlySalary.value !== undefined && x.job.startYear !== undefined) {
- return {
- OffersIntern: {
- create: {
- internshipCycle: x.job.internshipCycle,
- monthlySalary: {
- create: {
- currency: x.job.monthlySalary?.currency,
- value: x.job.monthlySalary?.value
- }
- },
- specialization: x.job.specialization,
- startYear: x.job.startYear,
- title: x.job.title,
- }
- },
- comments: x.comments,
- company: {
- connect: {
- id: x.companyId
- }
- },
- jobType: x.jobType,
- location: x.location,
- monthYearReceived: x.monthYearReceived,
- negotiationStrategy: x.negotiationStrategy
- }
- }
- if (x.jobType === "FULLTIME" && x.job.base?.currency !== undefined && x.job.base?.value !== undefined && x.job.bonus?.currency !== undefined && x.job.bonus?.value !== undefined && x.job.stocks?.currency !== undefined && x.job.stocks?.value !== undefined && x.job.totalCompensation?.currency !== undefined && x.job.totalCompensation?.value !== undefined && x.job.level !== undefined) {
- return {
- OffersFullTime: {
- create: {
- baseSalary: {
- create: {
- currency: x.job.base?.currency,
- value: x.job.base?.value
- }
- },
- bonus: {
- create: {
- currency: x.job.bonus?.currency,
- value: x.job.bonus?.value
- }
- },
- level: x.job.level,
- specialization: x.job.specialization,
- stocks: {
- create: {
- currency: x.job.stocks?.currency,
- value: x.job.stocks?.value,
- }
- },
- title: x.job.title,
- totalCompensation: {
- create: {
- currency: x.job.totalCompensation?.currency,
- value: x.job.totalCompensation?.value,
- }
- },
- }
- },
- comments: x.comments,
- company: {
- connect: {
- id: x.companyId
- }
- },
- jobType: x.jobType,
- location: x.location,
- monthYearReceived: x.monthYearReceived,
- negotiationStrategy: x.negotiationStrategy
- }
- }
-
- // Throw error
- throw Prisma.PrismaClientKnownRequestError
- })
- },
- profileName: randomUUID(),
- },
- include: {
- background: {
- include: {
- educations: true,
- experiences: {
- include: {
- company: true,
- monthlySalary: true,
- totalCompensation: true
- }
- },
- specificYoes: true
- }
- },
- offers: {
- include: {
- OffersFullTime: {
- include: {
- baseSalary: true,
- bonus: true,
- stocks: true,
- totalCompensation: true
- }
- },
- OffersIntern: {
- include: {
- monthlySalary: true
- }
- }
- }
- }
- },
- });
-
- // TODO: add analysis to profile object then return
- return profile
- }
- },
-);
diff --git a/apps/portal/src/server/router/offers/offers-analysis-router.ts b/apps/portal/src/server/router/offers/offers-analysis-router.ts
new file mode 100644
index 00000000..de92b546
--- /dev/null
+++ b/apps/portal/src/server/router/offers/offers-analysis-router.ts
@@ -0,0 +1,470 @@
+import { z } from 'zod';
+import type {
+ Company,
+ OffersBackground,
+ OffersCurrency,
+ OffersFullTime,
+ OffersIntern,
+ OffersOffer,
+ OffersProfile,
+} from '@prisma/client';
+import { TRPCError } from '@trpc/server';
+
+import { profileAnalysisDtoMapper } from '~/mappers/offers-mappers';
+
+import { createRouter } from '../context';
+
+const searchOfferPercentile = (
+ offer: OffersOffer & {
+ OffersFullTime:
+ | (OffersFullTime & {
+ baseSalary: OffersCurrency;
+ bonus: OffersCurrency;
+ stocks: OffersCurrency;
+ totalCompensation: OffersCurrency;
+ })
+ | null;
+ OffersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ company: Company;
+ profile: OffersProfile & { background: OffersBackground | null };
+ },
+ similarOffers: Array<
+ OffersOffer & {
+ OffersFullTime:
+ | (OffersFullTime & {
+ totalCompensation: OffersCurrency;
+ })
+ | null;
+ OffersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ company: Company;
+ profile: OffersProfile & { background: OffersBackground | null };
+ }
+ >,
+) => {
+ for (let i = 0; i < similarOffers.length; i++) {
+ if (similarOffers[i].id === offer.id) {
+ return i;
+ }
+ }
+
+ return -1;
+};
+
+export const offersAnalysisRouter = createRouter()
+ .query('generate', {
+ input: z.object({
+ profileId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ await ctx.prisma.offersAnalysis.deleteMany({
+ where: {
+ profileId: input.profileId,
+ },
+ });
+
+ const offers = await ctx.prisma.offersOffer.findMany({
+ include: {
+ OffersFullTime: {
+ include: {
+ baseSalary: true,
+ bonus: true,
+ stocks: true,
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: true,
+ },
+ },
+ },
+ orderBy: [
+ {
+ OffersFullTime: {
+ totalCompensation: {
+ value: 'desc',
+ },
+ },
+ },
+ {
+ OffersIntern: {
+ monthlySalary: {
+ value: 'desc',
+ },
+ },
+ },
+ ],
+ where: {
+ profileId: input.profileId,
+ },
+ });
+
+ if (!offers || offers.length === 0) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'No offers found on this profile',
+ });
+ }
+
+ const overallHighestOffer = offers[0];
+
+ // TODO: Shift yoe out of background to make it mandatory
+ if (
+ !overallHighestOffer.profile.background ||
+ !overallHighestOffer.profile.background.totalYoe
+ ) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Cannot analyse without YOE',
+ });
+ }
+
+ const yoe = overallHighestOffer.profile.background.totalYoe as number;
+
+ let similarOffers = await ctx.prisma.offersOffer.findMany({
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: [
+ {
+ OffersFullTime: {
+ totalCompensation: {
+ value: 'desc',
+ },
+ },
+ },
+ {
+ OffersIntern: {
+ monthlySalary: {
+ value: 'desc',
+ },
+ },
+ },
+ ],
+ where: {
+ AND: [
+ {
+ location: overallHighestOffer.location,
+ },
+ {
+ OR: [
+ {
+ OffersFullTime: {
+ level: overallHighestOffer.OffersFullTime?.level,
+ specialization:
+ overallHighestOffer.OffersFullTime?.specialization,
+ },
+ OffersIntern: {
+ specialization:
+ overallHighestOffer.OffersIntern?.specialization,
+ },
+ },
+ ],
+ },
+ {
+ profile: {
+ background: {
+ AND: [
+ {
+ totalYoe: {
+ gte: Math.max(yoe - 1, 0),
+ lte: yoe + 1,
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ let similarCompanyOffers = similarOffers.filter(
+ (offer) => offer.companyId === overallHighestOffer.companyId,
+ );
+
+ // CALCULATE PERCENTILES
+ const overallIndex = searchOfferPercentile(
+ overallHighestOffer,
+ similarOffers,
+ );
+ const overallPercentile =
+ similarOffers.length === 0 ? 0 : overallIndex / similarOffers.length;
+
+ const companyIndex = searchOfferPercentile(
+ overallHighestOffer,
+ similarCompanyOffers,
+ );
+ const companyPercentile =
+ similarCompanyOffers.length === 0
+ ? 0
+ : companyIndex / similarCompanyOffers.length;
+
+ // FIND TOP >=90 PERCENTILE OFFERS
+ similarOffers = similarOffers.filter(
+ (offer) => offer.id !== overallHighestOffer.id,
+ );
+ similarCompanyOffers = similarCompanyOffers.filter(
+ (offer) => offer.id !== overallHighestOffer.id,
+ );
+
+ const noOfSimilarOffers = similarOffers.length;
+ const similarOffers90PercentileIndex =
+ Math.floor(noOfSimilarOffers * 0.9) - 1;
+ const topPercentileOffers =
+ noOfSimilarOffers > 1
+ ? similarOffers.slice(
+ similarOffers90PercentileIndex,
+ similarOffers90PercentileIndex + 2,
+ )
+ : similarOffers;
+
+ const noOfSimilarCompanyOffers = similarCompanyOffers.length;
+ const similarCompanyOffers90PercentileIndex =
+ Math.floor(noOfSimilarCompanyOffers * 0.9) - 1;
+ const topPercentileCompanyOffers =
+ noOfSimilarCompanyOffers > 1
+ ? similarCompanyOffers.slice(
+ similarCompanyOffers90PercentileIndex,
+ similarCompanyOffers90PercentileIndex + 2,
+ )
+ : similarCompanyOffers;
+
+ const analysis = await ctx.prisma.offersAnalysis.create({
+ data: {
+ companyPercentile,
+ noOfSimilarCompanyOffers,
+ noOfSimilarOffers,
+ overallHighestOffer: {
+ connect: {
+ id: overallHighestOffer.id,
+ },
+ },
+ overallPercentile,
+ profile: {
+ connect: {
+ id: input.profileId,
+ },
+ },
+ topCompanyOffers: {
+ connect: topPercentileCompanyOffers.map((offer) => {
+ return { id: offer.id };
+ }),
+ },
+ topOverallOffers: {
+ connect: topPercentileOffers.map((offer) => {
+ return { id: offer.id };
+ }),
+ },
+ },
+ include: {
+ overallHighestOffer: {
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: true,
+ },
+ },
+ },
+ },
+ topCompanyOffers: {
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ topOverallOffers: {
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return profileAnalysisDtoMapper(analysis);
+ },
+ })
+ .query('get', {
+ input: z.object({
+ profileId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const analysis = await ctx.prisma.offersAnalysis.findFirst({
+ include: {
+ overallHighestOffer: {
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: true,
+ },
+ },
+ },
+ },
+ topCompanyOffers: {
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ topOverallOffers: {
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ where: {
+ profileId: input.profileId,
+ },
+ });
+
+ if (!analysis) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'No analysis found on this profile',
+ });
+ }
+
+ return profileAnalysisDtoMapper(analysis);
+ },
+ });
diff --git a/apps/portal/src/server/router/offers/offers-comments-router.ts b/apps/portal/src/server/router/offers/offers-comments-router.ts
new file mode 100644
index 00000000..2e6b9e38
--- /dev/null
+++ b/apps/portal/src/server/router/offers/offers-comments-router.ts
@@ -0,0 +1,335 @@
+import { z } from 'zod';
+import * as trpc from '@trpc/server';
+
+import { createRouter } from '../context';
+
+import type { OffersDiscussion, Reply } from '~/types/offers';
+
+export const offersCommentsRouter = createRouter()
+ .query('getComments', {
+ input: z.object({
+ profileId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ const result = await ctx.prisma.offersProfile.findFirst({
+ include: {
+ discussion: {
+ include: {
+ replies: {
+ include: {
+ user: true,
+ },
+ orderBy: {
+ createdAt: 'desc'
+ }
+ },
+ replyingTo: true,
+ user: true,
+ },
+ orderBy: {
+ createdAt: 'desc'
+ }
+ },
+ },
+ where: {
+ id: input.profileId,
+ }
+ });
+
+ const discussions: OffersDiscussion = {
+ data: result?.discussion
+ .filter((x) => {
+ return x.replyingToId === null
+ })
+ .map((x) => {
+ if (x.user == null) {
+ x.user = {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '
',
+ };
+ }
+
+ x.replies?.map((y) => {
+ if (y.user == null) {
+ y.user = {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '',
+ };
+ }
+ });
+
+ const replyType: Reply = {
+ createdAt: x.createdAt,
+ id: x.id,
+ message: x.message,
+ replies: x.replies.map((reply) => {
+ return {
+ createdAt: reply.createdAt,
+ id: reply.id,
+ message: reply.message,
+ replies: [],
+ replyingToId: reply.replyingToId,
+ user: reply.user
+ }
+ }),
+ replyingToId: x.replyingToId,
+ user: x.user
+ }
+
+ return replyType
+ }) ?? []
+ }
+
+ return discussions
+ },
+ })
+ .mutation('create', {
+ input: z.object({
+ message: z.string(),
+ profileId: z.string(),
+ replyingToId: z.string().optional(),
+ token: z.string().optional(),
+ userId: z.string().optional()
+ }),
+ async resolve({ ctx, input }) {
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ const profileEditToken = profile?.editToken;
+
+ if (input.token === profileEditToken || input.userId) {
+ const createdReply = await ctx.prisma.offersReply.create({
+ data: {
+ message: input.message,
+ profile: {
+ connect: {
+ id: input.profileId,
+ },
+ },
+ },
+ });
+
+ if (input.replyingToId) {
+ await ctx.prisma.offersReply.update({
+ data: {
+ replyingTo: {
+ connect: {
+ id: input.replyingToId,
+ },
+ },
+ },
+ where: {
+ id: createdReply.id,
+ },
+ });
+ }
+
+ if (input.userId) {
+ await ctx.prisma.offersReply.update({
+ data: {
+ user: {
+ connect: {
+ id: input.userId,
+ },
+ },
+ },
+ where: {
+ id: createdReply.id,
+ },
+ });
+ }
+
+ const created = await ctx.prisma.offersReply.findFirst({
+ include: {
+ user: true
+ },
+ where: {
+ id: createdReply.id,
+ },
+ });
+
+ const result: Reply = {
+ createdAt: created!.createdAt,
+ id: created!.id,
+ message: created!.message,
+ replies: [], // New message should have no replies
+ replyingToId: created!.replyingToId,
+ user: created!.user ?? {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '',
+ }
+ }
+
+ return result
+ }
+
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Missing userId or wrong token.',
+ });
+ },
+ })
+ .mutation('update', {
+ input: z.object({
+ id: z.string(),
+ message: z.string(),
+ profileId: z.string(),
+ // Have to pass in either userID or token for validation
+ token: z.string().optional(),
+ userId: z.string().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const messageToUpdate = await ctx.prisma.offersReply.findFirst({
+ where: {
+ id: input.id,
+ },
+ });
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ const profileEditToken = profile?.editToken;
+
+ // To validate user editing, OP or correct user
+ // TODO: improve validation process
+ if (
+ profileEditToken === input.token ||
+ messageToUpdate?.userId === input.userId
+ ) {
+ const updated = await ctx.prisma.offersReply.update({
+ data: {
+ message: input.message,
+ },
+ include: {
+ replies: {
+ include: {
+ user: true
+ }
+ },
+ user: true
+ },
+ where: {
+ id: input.id,
+ },
+ });
+
+ const result: Reply = {
+ createdAt: updated!.createdAt,
+ id: updated!.id,
+ message: updated!.message,
+ replies: updated!.replies.map((x) => {
+ return {
+ createdAt: x.createdAt,
+ id: x.id,
+ message: x.message,
+ replies: [],
+ replyingToId: x.replyingToId,
+ user: x.user ?? {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '',
+ }
+ }
+ }),
+ replyingToId: updated!.replyingToId,
+ user: updated!.user ?? {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '',
+ }
+ }
+
+ return result
+ }
+
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Wrong userId or token.',
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ id: z.string(),
+ profileId: z.string(),
+ // Have to pass in either userID or token for validation
+ token: z.string().optional(),
+ userId: z.string().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const messageToDelete = await ctx.prisma.offersReply.findFirst({
+ where: {
+ id: input.id,
+ },
+ });
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ const profileEditToken = profile?.editToken;
+
+ // To validate user editing, OP or correct user
+ // TODO: improve validation process
+ if (
+ profileEditToken === input.token ||
+ messageToDelete?.userId === input.userId
+ ) {
+ await ctx.prisma.offersReply.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ await ctx.prisma.offersProfile.findFirst({
+ include: {
+ discussion: {
+ include: {
+ replies: true,
+ replyingTo: true,
+ user: true,
+ },
+ },
+ },
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ // If (result) {
+ // return result.discussion.filter((x) => x.replyingToId === null);
+ // }
+
+ // return result;
+ }
+
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Wrong userId or token.',
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/offers/offers-profile-router.ts b/apps/portal/src/server/router/offers/offers-profile-router.ts
new file mode 100644
index 00000000..11f74c3e
--- /dev/null
+++ b/apps/portal/src/server/router/offers/offers-profile-router.ts
@@ -0,0 +1,1123 @@
+import crypto, { randomUUID } from 'crypto';
+import { z } from 'zod';
+import * as trpc from '@trpc/server';
+
+import {
+ addToProfileResponseMapper,
+ createOfferProfileResponseMapper,
+ profileDtoMapper,
+} from '~/mappers/offers-mappers';
+
+import { createRouter } from '../context';
+
+const valuation = z.object({
+ currency: z.string(),
+ id: z.string().optional(),
+ value: z.number(),
+});
+
+const company = z.object({
+ createdAt: z.date(),
+ description: z.string().nullish(),
+ id: z.string().optional(),
+ logoUrl: z.string().nullish(),
+ name: z.string(),
+ slug: z.string(),
+ updatedAt: z.date(),
+});
+
+const offer = z.object({
+ OffersFullTime: z
+ .object({
+ baseSalary: valuation.nullish(),
+ baseSalaryId: z.string().nullish(),
+ bonus: valuation.nullish(),
+ bonusId: z.string().nullish(),
+ id: z.string().optional(),
+ level: z.string().nullish(),
+ specialization: z.string(),
+ stocks: valuation.nullish(),
+ stocksId: z.string().nullish(),
+ title: z.string(),
+ totalCompensation: valuation.nullish(),
+ totalCompensationId: z.string().nullish(),
+ })
+ .nullish(),
+ OffersIntern: z
+ .object({
+ id: z.string().optional(),
+ internshipCycle: z.string().nullish(),
+ monthlySalary: valuation.nullish(),
+ specialization: z.string(),
+ startYear: z.number().nullish(),
+ title: z.string(),
+ totalCompensation: valuation.nullish(), // Full time
+ })
+ .nullish(),
+ comments: z.string(),
+ company: company.nullish(),
+ companyId: z.string(),
+ id: z.string().optional(),
+ jobType: z.string(),
+ location: z.string(),
+ monthYearReceived: z.date(),
+ negotiationStrategy: z.string(),
+ offersFullTimeId: z.string().nullish(),
+ offersInternId: z.string().nullish(),
+ profileId: z.string().nullish(),
+});
+
+const experience = z.object({
+ backgroundId: z.string().nullish(),
+ company: company.nullish(),
+ companyId: z.string().nullish(),
+ durationInMonths: z.number().nullish(),
+ id: z.string().optional(),
+ jobType: z.string().nullish(),
+ level: z.string().nullish(),
+ monthlySalary: valuation.nullish(),
+ monthlySalaryId: z.string().nullish(),
+ specialization: z.string().nullish(),
+ title: z.string().nullish(),
+ totalCompensation: valuation.nullish(),
+ totalCompensationId: z.string().nullish(),
+});
+
+const education = z.object({
+ backgroundId: z.string().nullish(),
+ endDate: z.date().nullish(),
+ field: z.string().nullish(),
+ id: z.string().optional(),
+ school: z.string().nullish(),
+ startDate: z.date().nullish(),
+ type: z.string().nullish(),
+});
+
+const reply = z.object({
+ createdAt: z.date().nullish(),
+ id: z.string().optional(),
+ messages: z.string().nullish(),
+ profileId: z.string().nullish(),
+ replyingToId: z.string().nullish(),
+ userId: z.string().nullish(),
+});
+
+export const offersProfileRouter = createRouter()
+ .query('listOne', {
+ input: z.object({
+ profileId: z.string(),
+ token: z.string().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const result = await ctx.prisma.offersProfile.findFirst({
+ include: {
+ analysis: {
+ include: {
+ overallHighestOffer: {
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: true,
+ },
+ },
+ },
+ },
+ topCompanyOffers: {
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ topOverallOffers: {
+ include: {
+ OffersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ background: {
+ include: {
+ educations: true,
+ experiences: {
+ include: {
+ company: true,
+ monthlySalary: true,
+ totalCompensation: true,
+ },
+ },
+ specificYoes: true,
+ },
+ },
+ discussion: {
+ include: {
+ replies: true,
+ replyingTo: true,
+ user: true,
+ },
+ },
+ offers: {
+ include: {
+ OffersFullTime: {
+ include: {
+ baseSalary: true,
+ bonus: true,
+ stocks: true,
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ },
+ },
+ },
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ if (result) {
+ return profileDtoMapper(result, input.token);
+ }
+
+ throw new trpc.TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Profile does not exist',
+ });
+ },
+ })
+ .mutation('create', {
+ input: z.object({
+ background: z.object({
+ educations: z.array(education),
+ experiences: z.array(experience),
+ specificYoes: z.array(
+ z.object({
+ domain: z.string(),
+ yoe: z.number(),
+ }),
+ ),
+ totalYoe: z.number(),
+ }),
+ offers: z.array(offer),
+ }),
+ async resolve({ ctx, input }) {
+ // TODO: add more
+ const token = crypto
+ .createHash('sha256')
+ .update(Date.now().toString())
+ .digest('hex');
+
+ const profile = await ctx.prisma.offersProfile.create({
+ data: {
+ background: {
+ create: {
+ educations: {
+ create: input.background.educations.map((x) => ({
+ endDate: x.endDate,
+ field: x.field,
+ school: x.school,
+ startDate: x.startDate,
+ type: x.type,
+ })),
+ },
+ experiences: {
+ create: input.background.experiences.map((x) => {
+ if (
+ x.jobType === 'FULLTIME' &&
+ x.totalCompensation?.currency !== undefined &&
+ x.totalCompensation.value !== undefined
+ ) {
+ if (x.companyId) {
+ return {
+ company: {
+ connect: {
+ id: x.companyId,
+ },
+ },
+ durationInMonths: x.durationInMonths,
+ jobType: x.jobType,
+ level: x.level,
+ specialization: x.specialization,
+ title: x.title,
+ totalCompensation: {
+ create: {
+ currency: x.totalCompensation?.currency,
+ value: x.totalCompensation?.value,
+ },
+ },
+ };
+ }
+ return {
+ durationInMonths: x.durationInMonths,
+ jobType: x.jobType,
+ level: x.level,
+ specialization: x.specialization,
+ title: x.title,
+ totalCompensation: {
+ create: {
+ currency: x.totalCompensation?.currency,
+ value: x.totalCompensation?.value,
+ },
+ },
+ };
+ }
+ if (
+ x.jobType === 'INTERN' &&
+ x.monthlySalary?.currency !== undefined &&
+ x.monthlySalary.value !== undefined
+ ) {
+ if (x.companyId) {
+ return {
+ company: {
+ connect: {
+ id: x.companyId,
+ },
+ },
+ durationInMonths: x.durationInMonths,
+ jobType: x.jobType,
+ monthlySalary: {
+ create: {
+ currency: x.monthlySalary?.currency,
+ value: x.monthlySalary?.value,
+ },
+ },
+ specialization: x.specialization,
+ title: x.title,
+ };
+ }
+ return {
+ durationInMonths: x.durationInMonths,
+ jobType: x.jobType,
+ monthlySalary: {
+ create: {
+ currency: x.monthlySalary?.currency,
+ value: x.monthlySalary?.value,
+ },
+ },
+ specialization: x.specialization,
+ title: x.title,
+ };
+ }
+
+ throw new trpc.TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Missing fields.',
+ });
+ }),
+ },
+ specificYoes: {
+ create: input.background.specificYoes.map((x) => {
+ return {
+ domain: x.domain,
+ yoe: x.yoe,
+ };
+ }),
+ },
+ totalYoe: input.background.totalYoe,
+ },
+ },
+ editToken: token,
+ offers: {
+ create: input.offers.map((x) => {
+ if (
+ x.jobType === 'INTERN' &&
+ x.OffersIntern &&
+ x.OffersIntern.internshipCycle &&
+ x.OffersIntern.monthlySalary?.currency &&
+ x.OffersIntern.monthlySalary.value &&
+ x.OffersIntern.startYear
+ ) {
+ return {
+ OffersIntern: {
+ create: {
+ internshipCycle: x.OffersIntern.internshipCycle,
+ monthlySalary: {
+ create: {
+ currency: x.OffersIntern.monthlySalary?.currency,
+ value: x.OffersIntern.monthlySalary?.value,
+ },
+ },
+ specialization: x.OffersIntern.specialization,
+ startYear: x.OffersIntern.startYear,
+ title: x.OffersIntern.title,
+ },
+ },
+ comments: x.comments,
+ company: {
+ connect: {
+ id: x.companyId,
+ },
+ },
+ jobType: x.jobType,
+ location: x.location,
+ monthYearReceived: x.monthYearReceived,
+ negotiationStrategy: x.negotiationStrategy,
+ };
+ }
+ if (
+ x.jobType === 'FULLTIME' &&
+ x.OffersFullTime &&
+ x.OffersFullTime.baseSalary?.currency &&
+ x.OffersFullTime.baseSalary?.value &&
+ x.OffersFullTime.bonus?.currency &&
+ x.OffersFullTime.bonus?.value &&
+ x.OffersFullTime.stocks?.currency &&
+ x.OffersFullTime.stocks?.value &&
+ x.OffersFullTime.totalCompensation?.currency &&
+ x.OffersFullTime.totalCompensation?.value &&
+ x.OffersFullTime.level
+ ) {
+ return {
+ OffersFullTime: {
+ create: {
+ baseSalary: {
+ create: {
+ currency: x.OffersFullTime.baseSalary?.currency,
+ value: x.OffersFullTime.baseSalary?.value,
+ },
+ },
+ bonus: {
+ create: {
+ currency: x.OffersFullTime.bonus?.currency,
+ value: x.OffersFullTime.bonus?.value,
+ },
+ },
+ level: x.OffersFullTime.level,
+ specialization: x.OffersFullTime.specialization,
+ stocks: {
+ create: {
+ currency: x.OffersFullTime.stocks?.currency,
+ value: x.OffersFullTime.stocks?.value,
+ },
+ },
+ title: x.OffersFullTime.title,
+ totalCompensation: {
+ create: {
+ currency:
+ x.OffersFullTime.totalCompensation?.currency,
+ value: x.OffersFullTime.totalCompensation?.value,
+ },
+ },
+ },
+ },
+ comments: x.comments,
+ company: {
+ connect: {
+ id: x.companyId,
+ },
+ },
+ jobType: x.jobType,
+ location: x.location,
+ monthYearReceived: x.monthYearReceived,
+ negotiationStrategy: x.negotiationStrategy,
+ };
+ }
+
+ // Throw error
+ throw new trpc.TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Missing fields.',
+ });
+ }),
+ },
+ profileName: randomUUID().substring(0, 10),
+ },
+ });
+
+ return createOfferProfileResponseMapper(profile, token);
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ profileId: z.string(),
+ token: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const profileToDelete = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+ const profileEditToken = profileToDelete?.editToken;
+
+ if (profileEditToken === input.token) {
+ const deletedProfile = await ctx.prisma.offersProfile.delete({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ return deletedProfile.id;
+ }
+ // TODO: Throw 401
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Invalid token.',
+ });
+ },
+ })
+ .mutation('update', {
+ input: z.object({
+ background: z.object({
+ educations: z.array(education),
+ experiences: z.array(experience),
+ id: z.string().optional(),
+ offersProfileId: z.string().optional(),
+ specificYoes: z.array(
+ z.object({
+ backgroundId: z.string().optional(),
+ domain: z.string(),
+ id: z.string().optional(),
+ yoe: z.number(),
+ }),
+ ),
+ totalYoe: z.number(),
+ }),
+ createdAt: z.string().optional(),
+ discussion: z.array(reply),
+ id: z.string(),
+ isEditable: z.boolean().nullish(),
+ offers: z.array(offer),
+ profileName: z.string(),
+ token: z.string(),
+ userId: z.string().nullish(),
+ }),
+ async resolve({ ctx, input }) {
+ const profileToUpdate = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.id,
+ },
+ });
+ const profileEditToken = profileToUpdate?.editToken;
+
+ if (profileEditToken === input.token) {
+ await ctx.prisma.offersProfile.update({
+ data: {
+ profileName: input.profileName,
+ },
+ where: {
+ id: input.id,
+ },
+ });
+
+ await ctx.prisma.offersBackground.update({
+ data: {
+ totalYoe: input.background.totalYoe,
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+
+ for (const edu of input.background.educations) {
+ if (edu.id) {
+ await ctx.prisma.offersEducation.update({
+ data: {
+ endDate: edu.endDate,
+ field: edu.field,
+ school: edu.school,
+ startDate: edu.startDate,
+ type: edu.type,
+ },
+ where: {
+ id: edu.id,
+ },
+ });
+ } else {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ educations: {
+ create: {
+ endDate: edu.endDate,
+ field: edu.field,
+ school: edu.school,
+ startDate: edu.startDate,
+ type: edu.type,
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ }
+ }
+
+ for (const exp of input.background.experiences) {
+ if (exp.id) {
+ await ctx.prisma.offersExperience.update({
+ data: {
+ companyId: exp.companyId,
+ durationInMonths: exp.durationInMonths,
+ level: exp.level,
+ specialization: exp.specialization,
+ },
+ where: {
+ id: exp.id,
+ },
+ });
+
+ if (exp.monthlySalary) {
+ await ctx.prisma.offersCurrency.update({
+ data: {
+ currency: exp.monthlySalary.currency,
+ value: exp.monthlySalary.value,
+ },
+ where: {
+ id: exp.monthlySalary.id,
+ },
+ });
+ }
+
+ if (exp.totalCompensation) {
+ await ctx.prisma.offersCurrency.update({
+ data: {
+ currency: exp.totalCompensation.currency,
+ value: exp.totalCompensation.value,
+ },
+ where: {
+ id: exp.totalCompensation.id,
+ },
+ });
+ }
+ } else if (!exp.id) {
+ if (
+ exp.jobType === 'FULLTIME' &&
+ exp.totalCompensation?.currency !== undefined &&
+ exp.totalCompensation.value !== undefined
+ ) {
+ if (exp.companyId) {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ experiences: {
+ create: {
+ company: {
+ connect: {
+ id: exp.companyId,
+ },
+ },
+ durationInMonths: exp.durationInMonths,
+ jobType: exp.jobType,
+ level: exp.level,
+ specialization: exp.specialization,
+ title: exp.title,
+ totalCompensation: {
+ create: {
+ currency: exp.totalCompensation?.currency,
+ value: exp.totalCompensation?.value,
+ },
+ },
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ } else {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ experiences: {
+ create: {
+ durationInMonths: exp.durationInMonths,
+ jobType: exp.jobType,
+ level: exp.level,
+ specialization: exp.specialization,
+ title: exp.title,
+ totalCompensation: {
+ create: {
+ currency: exp.totalCompensation?.currency,
+ value: exp.totalCompensation?.value,
+ },
+ },
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ }
+ } else if (
+ exp.jobType === 'INTERN' &&
+ exp.monthlySalary?.currency !== undefined &&
+ exp.monthlySalary.value !== undefined
+ ) {
+ if (exp.companyId) {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ experiences: {
+ create: {
+ company: {
+ connect: {
+ id: exp.companyId,
+ },
+ },
+ durationInMonths: exp.durationInMonths,
+ jobType: exp.jobType,
+ monthlySalary: {
+ create: {
+ currency: exp.monthlySalary?.currency,
+ value: exp.monthlySalary?.value,
+ },
+ },
+ specialization: exp.specialization,
+ title: exp.title,
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ } else {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ experiences: {
+ create: {
+ durationInMonths: exp.durationInMonths,
+ jobType: exp.jobType,
+ monthlySalary: {
+ create: {
+ currency: exp.monthlySalary?.currency,
+ value: exp.monthlySalary?.value,
+ },
+ },
+ specialization: exp.specialization,
+ title: exp.title,
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ }
+ }
+ }
+ }
+
+ for (const yoe of input.background.specificYoes) {
+ if (yoe.id) {
+ await ctx.prisma.offersSpecificYoe.update({
+ data: {
+ ...yoe,
+ },
+ where: {
+ id: yoe.id,
+ },
+ });
+ } else {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ specificYoes: {
+ create: {
+ domain: yoe.domain,
+ yoe: yoe.yoe,
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ }
+ }
+
+ for (const offerToUpdate of input.offers) {
+ if (offerToUpdate.id) {
+ await ctx.prisma.offersOffer.update({
+ data: {
+ comments: offerToUpdate.comments,
+ companyId: offerToUpdate.companyId,
+ location: offerToUpdate.location,
+ monthYearReceived: offerToUpdate.monthYearReceived,
+ negotiationStrategy: offerToUpdate.negotiationStrategy,
+ },
+ where: {
+ id: offerToUpdate.id,
+ },
+ });
+
+ if (
+ offerToUpdate.jobType === 'INTERN' ||
+ offerToUpdate.jobType === 'FULLTIME'
+ ) {
+ await ctx.prisma.offersOffer.update({
+ data: {
+ jobType: offerToUpdate.jobType,
+ },
+ where: {
+ id: offerToUpdate.id,
+ },
+ });
+ }
+
+ if (offerToUpdate.OffersIntern?.monthlySalary) {
+ await ctx.prisma.offersIntern.update({
+ data: {
+ internshipCycle:
+ offerToUpdate.OffersIntern.internshipCycle ?? undefined,
+ specialization: offerToUpdate.OffersIntern.specialization,
+ startYear: offerToUpdate.OffersIntern.startYear ?? undefined,
+ title: offerToUpdate.OffersIntern.title,
+ },
+ where: {
+ id: offerToUpdate.OffersIntern.id,
+ },
+ });
+ await ctx.prisma.offersCurrency.update({
+ data: {
+ currency: offerToUpdate.OffersIntern.monthlySalary.currency,
+ value: offerToUpdate.OffersIntern.monthlySalary.value,
+ },
+ where: {
+ id: offerToUpdate.OffersIntern.monthlySalary.id,
+ },
+ });
+ }
+
+ if (offerToUpdate.OffersFullTime?.totalCompensation) {
+ await ctx.prisma.offersFullTime.update({
+ data: {
+ level: offerToUpdate.OffersFullTime.level ?? undefined,
+ specialization: offerToUpdate.OffersFullTime.specialization,
+ title: offerToUpdate.OffersFullTime.title,
+ },
+ where: {
+ id: offerToUpdate.OffersFullTime.id,
+ },
+ });
+ if (offerToUpdate.OffersFullTime.baseSalary) {
+ await ctx.prisma.offersCurrency.update({
+ data: {
+ currency: offerToUpdate.OffersFullTime.baseSalary.currency,
+ value: offerToUpdate.OffersFullTime.baseSalary.value,
+ },
+ where: {
+ id: offerToUpdate.OffersFullTime.baseSalary.id,
+ },
+ });
+ }
+ if (offerToUpdate.OffersFullTime.bonus) {
+ await ctx.prisma.offersCurrency.update({
+ data: {
+ currency: offerToUpdate.OffersFullTime.bonus.currency,
+ value: offerToUpdate.OffersFullTime.bonus.value,
+ },
+ where: {
+ id: offerToUpdate.OffersFullTime.bonus.id,
+ },
+ });
+ }
+ if (offerToUpdate.OffersFullTime.stocks) {
+ await ctx.prisma.offersCurrency.update({
+ data: {
+ currency: offerToUpdate.OffersFullTime.stocks.currency,
+ value: offerToUpdate.OffersFullTime.stocks.value,
+ },
+ where: {
+ id: offerToUpdate.OffersFullTime.stocks.id,
+ },
+ });
+ }
+ await ctx.prisma.offersCurrency.update({
+ data: {
+ currency:
+ offerToUpdate.OffersFullTime.totalCompensation.currency,
+ value: offerToUpdate.OffersFullTime.totalCompensation.value,
+ },
+ where: {
+ id: offerToUpdate.OffersFullTime.totalCompensation.id,
+ },
+ });
+ }
+ } else {
+ if (
+ offerToUpdate.jobType === 'INTERN' &&
+ offerToUpdate.OffersIntern &&
+ offerToUpdate.OffersIntern.internshipCycle &&
+ offerToUpdate.OffersIntern.monthlySalary?.currency &&
+ offerToUpdate.OffersIntern.monthlySalary.value &&
+ offerToUpdate.OffersIntern.startYear
+ ) {
+ await ctx.prisma.offersProfile.update({
+ data: {
+ offers: {
+ create: {
+ OffersIntern: {
+ create: {
+ internshipCycle:
+ offerToUpdate.OffersIntern.internshipCycle,
+ monthlySalary: {
+ create: {
+ currency:
+ offerToUpdate.OffersIntern.monthlySalary
+ ?.currency,
+ value:
+ offerToUpdate.OffersIntern.monthlySalary?.value,
+ },
+ },
+ specialization:
+ offerToUpdate.OffersIntern.specialization,
+ startYear: offerToUpdate.OffersIntern.startYear,
+ title: offerToUpdate.OffersIntern.title,
+ },
+ },
+ comments: offerToUpdate.comments,
+ company: {
+ connect: {
+ id: offerToUpdate.companyId,
+ },
+ },
+ jobType: offerToUpdate.jobType,
+ location: offerToUpdate.location,
+ monthYearReceived: offerToUpdate.monthYearReceived,
+ negotiationStrategy: offerToUpdate.negotiationStrategy,
+ },
+ },
+ },
+ where: {
+ id: input.id,
+ },
+ });
+ }
+ if (
+ offerToUpdate.jobType === 'FULLTIME' &&
+ offerToUpdate.OffersFullTime &&
+ offerToUpdate.OffersFullTime.baseSalary?.currency &&
+ offerToUpdate.OffersFullTime.baseSalary?.value &&
+ offerToUpdate.OffersFullTime.bonus?.currency &&
+ offerToUpdate.OffersFullTime.bonus?.value &&
+ offerToUpdate.OffersFullTime.stocks?.currency &&
+ offerToUpdate.OffersFullTime.stocks?.value &&
+ offerToUpdate.OffersFullTime.totalCompensation?.currency &&
+ offerToUpdate.OffersFullTime.totalCompensation?.value &&
+ offerToUpdate.OffersFullTime.level
+ ) {
+ await ctx.prisma.offersProfile.update({
+ data: {
+ offers: {
+ create: {
+ OffersFullTime: {
+ create: {
+ baseSalary: {
+ create: {
+ currency:
+ offerToUpdate.OffersFullTime.baseSalary
+ ?.currency,
+ value:
+ offerToUpdate.OffersFullTime.baseSalary?.value,
+ },
+ },
+ bonus: {
+ create: {
+ currency:
+ offerToUpdate.OffersFullTime.bonus?.currency,
+ value: offerToUpdate.OffersFullTime.bonus?.value,
+ },
+ },
+ level: offerToUpdate.OffersFullTime.level,
+ specialization:
+ offerToUpdate.OffersFullTime.specialization,
+ stocks: {
+ create: {
+ currency:
+ offerToUpdate.OffersFullTime.stocks?.currency,
+ value: offerToUpdate.OffersFullTime.stocks?.value,
+ },
+ },
+ title: offerToUpdate.OffersFullTime.title,
+ totalCompensation: {
+ create: {
+ currency:
+ offerToUpdate.OffersFullTime.totalCompensation
+ ?.currency,
+ value:
+ offerToUpdate.OffersFullTime.totalCompensation
+ ?.value,
+ },
+ },
+ },
+ },
+ comments: offerToUpdate.comments,
+ company: {
+ connect: {
+ id: offerToUpdate.companyId,
+ },
+ },
+ jobType: offerToUpdate.jobType,
+ location: offerToUpdate.location,
+ monthYearReceived: offerToUpdate.monthYearReceived,
+ negotiationStrategy: offerToUpdate.negotiationStrategy,
+ },
+ },
+ },
+ where: {
+ id: input.id,
+ },
+ });
+ }
+ }
+ }
+
+ const result = await ctx.prisma.offersProfile.findFirst({
+ include: {
+ background: {
+ include: {
+ educations: true,
+ experiences: {
+ include: {
+ company: true,
+ monthlySalary: true,
+ totalCompensation: true,
+ },
+ },
+ specificYoes: true,
+ },
+ },
+ discussion: {
+ include: {
+ replies: true,
+ replyingTo: true,
+ user: true,
+ },
+ },
+ offers: {
+ include: {
+ OffersFullTime: {
+ include: {
+ baseSalary: true,
+ bonus: true,
+ stocks: true,
+ totalCompensation: true,
+ },
+ },
+ OffersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ company: true,
+ },
+ },
+ },
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (result) {
+ return createOfferProfileResponseMapper(result, input.token);
+ }
+
+ throw new trpc.TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Profile does not exist',
+ });
+ }
+
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Invalid token.',
+ });
+ },
+ })
+ .mutation('addToUserProfile', {
+ input: z.object({
+ profileId: z.string(),
+ token: z.string(),
+ userId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ const profileEditToken = profile?.editToken;
+
+ if (profileEditToken === input.token) {
+ const updated = await ctx.prisma.offersProfile.update({
+ data: {
+ user: {
+ connect: {
+ id: input.userId,
+ },
+ },
+ },
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ return addToProfileResponseMapper(updated);
+ }
+
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Invalid token.',
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/offers.ts b/apps/portal/src/server/router/offers/offers.ts
similarity index 67%
rename from apps/portal/src/server/router/offers.ts
rename to apps/portal/src/server/router/offers/offers.ts
index 28e6c0eb..a35e2d2e 100644
--- a/apps/portal/src/server/router/offers.ts
+++ b/apps/portal/src/server/router/offers/offers.ts
@@ -1,7 +1,12 @@
-import assert from 'assert';
import { z } from 'zod';
+import { TRPCError } from '@trpc/server';
-import { createRouter } from './context';
+import {
+ dashboardOfferDtoMapper,
+ getOffersResponseMapper,
+} from '~/mappers/offers-mappers';
+
+import { createRouter } from '../context';
const yoeCategoryMap: Record = {
0: 'Internship',
@@ -16,13 +21,13 @@ const getYoeRange = (yoeCategory: number) => {
: yoeCategoryMap[yoeCategory] === 'Mid'
? { maxYoe: 7, minYoe: 4 }
: yoeCategoryMap[yoeCategory] === 'Senior'
- ? { maxYoe: null, minYoe: 8 }
- : null;
+ ? { maxYoe: 100, minYoe: 8 }
+ : null; // Internship
};
const ascOrder = '+';
const descOrder = '-';
-const sortingKeys = ['monthYearReceived', 'totalCompensation', 'yoe'];
+const sortingKeys = ['monthYearReceived', 'totalCompensation', 'totalYoe'];
const createSortByValidationRegex = () => {
const startsWithPlusOrMinusOnly = '^[+-]{1}';
@@ -32,10 +37,10 @@ const createSortByValidationRegex = () => {
export const offersRouter = createRouter().query('list', {
input: z.object({
- company: z.string().nullish(),
+ companyId: z.string().nullish(),
dateEnd: z.date().nullish(),
dateStart: z.date().nullish(),
- limit: z.number().nonnegative(),
+ limit: z.number().positive(),
location: z.string(),
offset: z.number().nonnegative(),
salaryMax: z.number().nullish(),
@@ -43,9 +48,13 @@ export const offersRouter = createRouter().query('list', {
sortBy: z.string().regex(createSortByValidationRegex()).nullish(),
title: z.string().nullish(),
yoeCategory: z.number().min(0).max(3),
+ yoeMax: z.number().max(100).nullish(),
+ yoeMin: z.number().min(0).nullish(),
}),
async resolve({ ctx, input }) {
const yoeRange = getYoeRange(input.yoeCategory);
+ const yoeMin = input.yoeMin ? input.yoeMin : yoeRange?.minYoe;
+ const yoeMax = input.yoeMax ? input.yoeMax : yoeRange?.maxYoe;
let data = !yoeRange
? await ctx.prisma.offersOffer.findMany({
@@ -65,9 +74,12 @@ export const offersRouter = createRouter().query('list', {
},
},
company: true,
+ profile: {
+ include: {
+ background: true,
+ },
+ },
},
- skip: input.limit * input.offset,
- take: input.limit,
where: {
AND: [
{
@@ -86,8 +98,8 @@ export const offersRouter = createRouter().query('list', {
],
},
})
- : yoeRange.maxYoe
- ? await ctx.prisma.offersOffer.findMany({
+ : await ctx.prisma.offersOffer.findMany({
+ // Junior, Mid, Senior
include: {
OffersFullTime: {
include: {
@@ -103,66 +115,12 @@ export const offersRouter = createRouter().query('list', {
},
},
company: true,
- },
- // Junior, Mid
- skip: input.limit * input.offset,
- take: input.limit,
- where: {
- AND: [
- {
- location: input.location,
- },
- {
- OffersIntern: {
- is: null,
- },
- },
- {
- OffersFullTime: {
- isNot: null,
- },
- },
- {
- profile: {
- background: {
- totalYoe: {
- gte: yoeRange.minYoe,
- },
- },
- },
- },
- {
- profile: {
- background: {
- totalYoe: {
- gte: yoeRange.maxYoe,
- },
- },
- },
- },
- ],
- },
- })
- : await ctx.prisma.offersOffer.findMany({
- // Senior
- include: {
- OffersFullTime: {
- include: {
- baseSalary: true,
- bonus: true,
- stocks: true,
- totalCompensation: true,
- },
- },
- OffersIntern: {
+ profile: {
include: {
- monthlySalary: true,
+ background: true,
},
},
- company: true,
},
- skip: input.limit * input.offset,
- take: input.limit,
where: {
AND: [
{
@@ -182,7 +140,8 @@ export const offersRouter = createRouter().query('list', {
profile: {
background: {
totalYoe: {
- gte: yoeRange.minYoe,
+ gte: yoeMin,
+ lte: yoeMax,
},
},
},
@@ -191,11 +150,12 @@ export const offersRouter = createRouter().query('list', {
},
});
+ // FILTERING
data = data.filter((offer) => {
let validRecord = true;
- if (input.company) {
- validRecord = validRecord && offer.company.name === input.company;
+ if (input.companyId) {
+ validRecord = validRecord && offer.company.id === input.companyId;
}
if (input.title) {
@@ -217,7 +177,12 @@ export const offersRouter = createRouter().query('list', {
? offer.OffersFullTime?.totalCompensation.value
: offer.OffersIntern?.monthlySalary.value;
- assert(salary);
+ if (!salary) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Total Compensation or Salary not found',
+ });
+ }
validRecord =
validRecord && salary >= input.salaryMin && salary <= input.salaryMax;
@@ -226,6 +191,7 @@ export const offersRouter = createRouter().query('list', {
return validRecord;
});
+ // SORTING
data = data.sort((offer1, offer2) => {
const defaultReturn =
offer2.monthYearReceived.getTime() - offer1.monthYearReceived.getTime();
@@ -255,10 +221,30 @@ export const offersRouter = createRouter().query('list', {
? offer2.OffersFullTime?.totalCompensation.value
: offer2.OffersIntern?.monthlySalary.value;
- if (salary1 && salary2) {
- return salary1 - salary2;
+ if (!salary1 || !salary2) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Total Compensation or Salary not found',
+ });
}
+
+ return salary1 - salary2;
+ }
+
+ if (sortingKey === 'totalYoe') {
+ const yoe1 = offer1.profile.background?.totalYoe;
+ const yoe2 = offer2.profile.background?.totalYoe;
+
+ if (!yoe1 || !yoe2) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Total years of experience not found',
+ });
+ }
+
+ return yoe1 - yoe2;
}
+
return defaultReturn;
})();
}
@@ -281,9 +267,28 @@ export const offersRouter = createRouter().query('list', {
? offer2.OffersFullTime?.totalCompensation.value
: offer2.OffersIntern?.monthlySalary.value;
- if (salary1 && salary2) {
- return salary2 - salary1;
+ if (!salary1 || !salary2) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Total Compensation or Salary not found',
+ });
+ }
+
+ return salary2 - salary1;
+ }
+
+ if (sortingKey === 'totalYoe') {
+ const yoe1 = offer1.profile.background?.totalYoe;
+ const yoe2 = offer2.profile.background?.totalYoe;
+
+ if (!yoe1 || !yoe2) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Total years of experience not found',
+ });
}
+
+ return yoe2 - yoe1;
}
return defaultReturn;
@@ -292,6 +297,21 @@ export const offersRouter = createRouter().query('list', {
return defaultReturn;
});
- return data;
+ const startRecordIndex: number = input.limit * input.offset;
+ const endRecordIndex: number =
+ startRecordIndex + input.limit <= data.length
+ ? startRecordIndex + input.limit
+ : data.length;
+ const paginatedData = data.slice(startRecordIndex, endRecordIndex);
+
+ return getOffersResponseMapper(
+ paginatedData.map((offer) => dashboardOfferDtoMapper(offer)),
+ {
+ currentPage: input.offset,
+ numOfItems: paginatedData.length,
+ numOfPages: Math.ceil(data.length / input.limit),
+ totalItems: data.length,
+ },
+ );
},
});
diff --git a/apps/portal/src/server/router/questions-answer-comment-router.ts b/apps/portal/src/server/router/questions-answer-comment-router.ts
index 51d17d34..7ed3e6e2 100644
--- a/apps/portal/src/server/router/questions-answer-comment-router.ts
+++ b/apps/portal/src/server/router/questions-answer-comment-router.ts
@@ -17,6 +17,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
include: {
user: {
select: {
+ image: true,
name: true,
},
},
@@ -26,7 +27,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
createdAt: 'desc',
},
where: {
- ...input,
+ answerId : input.answerId,
},
});
return questionAnswerCommentsData.map((data) => {
@@ -54,6 +55,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
numVotes: votes,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
+ userImage: data.user?.image ?? '',
};
return answerComment;
});
@@ -67,9 +69,12 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { answerId, content } = input;
+
return await ctx.prisma.questionsAnswerComment.create({
data: {
- ...input,
+ answerId,
+ content,
userId,
},
});
@@ -97,9 +102,10 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
});
}
+ const { content } = input;
return await ctx.prisma.questionsAnswerComment.update({
data: {
- ...input,
+ content,
},
where: {
id: input.id,
@@ -158,10 +164,13 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { answerCommentId, vote } = input;
+
return await ctx.prisma.questionsAnswerCommentVote.create({
data: {
- ...input,
+ answerCommentId,
userId,
+ vote,
},
});
},
@@ -182,7 +191,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
},
});
- if (voteToUpdate?.id !== userId) {
+ if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@@ -213,7 +222,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
},
});
- if (voteToDelete?.id !== userId) {
+ if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
diff --git a/apps/portal/src/server/router/questions-answer-router.ts b/apps/portal/src/server/router/questions-answer-router.ts
index 21095bf7..5d386854 100644
--- a/apps/portal/src/server/router/questions-answer-router.ts
+++ b/apps/portal/src/server/router/questions-answer-router.ts
@@ -12,6 +12,8 @@ export const questionsAnswerRouter = createProtectedRouter()
questionId: z.string(),
}),
async resolve({ ctx, input }) {
+ const { questionId } = input;
+
const answersData = await ctx.prisma.questionsAnswer.findMany({
include: {
_count: {
@@ -21,6 +23,7 @@ export const questionsAnswerRouter = createProtectedRouter()
},
user: {
select: {
+ image: true,
name: true,
},
},
@@ -30,7 +33,7 @@ export const questionsAnswerRouter = createProtectedRouter()
createdAt: 'desc',
},
where: {
- ...input,
+ questionId,
},
});
return answersData.map((data) => {
@@ -58,6 +61,7 @@ export const questionsAnswerRouter = createProtectedRouter()
numComments: data._count.comments,
numVotes: votes,
user: data.user?.name ?? '',
+ userImage: data.user?.image ?? '',
};
return answer;
});
@@ -77,6 +81,7 @@ export const questionsAnswerRouter = createProtectedRouter()
},
user: {
select: {
+ image: true,
name: true,
},
},
@@ -116,6 +121,7 @@ export const questionsAnswerRouter = createProtectedRouter()
numComments: answerData._count.comments,
numVotes: votes,
user: answerData.user?.name ?? '',
+ userImage: answerData.user?.image ?? '',
};
return answer;
},
@@ -128,9 +134,12 @@ export const questionsAnswerRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { content, questionId } = input;
+
return await ctx.prisma.questionsAnswer.create({
data: {
- ...input,
+ content,
+ questionId,
userId,
},
});
@@ -218,10 +227,13 @@ export const questionsAnswerRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { answerId, vote } = input;
+
return await ctx.prisma.questionsAnswerVote.create({
data: {
- ...input,
+ answerId,
userId,
+ vote,
},
});
},
@@ -241,7 +253,7 @@ export const questionsAnswerRouter = createProtectedRouter()
},
});
- if (voteToUpdate?.id !== userId) {
+ if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@@ -271,7 +283,7 @@ export const questionsAnswerRouter = createProtectedRouter()
},
});
- if (voteToDelete?.id !== userId) {
+ if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
diff --git a/apps/portal/src/server/router/questions-question-comment-router.ts b/apps/portal/src/server/router/questions-question-comment-router.ts
index 82345f06..e2f786f9 100644
--- a/apps/portal/src/server/router/questions-question-comment-router.ts
+++ b/apps/portal/src/server/router/questions-question-comment-router.ts
@@ -12,11 +12,13 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
questionId: z.string(),
}),
async resolve({ ctx, input }) {
+ const { questionId } = input;
const questionCommentsData =
await ctx.prisma.questionsQuestionComment.findMany({
include: {
user: {
select: {
+ image: true,
name: true,
},
},
@@ -26,7 +28,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
createdAt: 'desc',
},
where: {
- ...input,
+ questionId,
},
});
return questionCommentsData.map((data) => {
@@ -53,6 +55,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
id: data.id,
numVotes: votes,
user: data.user?.name ?? '',
+ userImage: data.user?.image ?? '',
};
return questionComment;
});
@@ -66,9 +69,12 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { content, questionId } = input;
+
return await ctx.prisma.questionsQuestionComment.create({
data: {
- ...input,
+ content,
+ questionId,
userId,
},
});
@@ -82,6 +88,8 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { content } = input;
+
const questionCommentToUpdate =
await ctx.prisma.questionsQuestionComment.findUnique({
where: {
@@ -98,7 +106,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
return await ctx.prisma.questionsQuestionComment.update({
data: {
- ...input,
+ content,
},
where: {
id: input.id,
@@ -156,11 +164,13 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { questionCommentId, vote } = input;
return await ctx.prisma.questionsQuestionCommentVote.create({
data: {
- ...input,
+ questionCommentId,
userId,
+ vote,
},
});
},
@@ -181,7 +191,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
},
});
- if (voteToUpdate?.id !== userId) {
+ if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@@ -212,7 +222,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
},
});
- if (voteToDelete?.id !== userId) {
+ if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
diff --git a/apps/portal/src/server/router/questions-question-router.ts b/apps/portal/src/server/router/questions-question-router.ts
index 32751e8e..1315a451 100644
--- a/apps/portal/src/server/router/questions-question-router.ts
+++ b/apps/portal/src/server/router/questions-question-router.ts
@@ -51,29 +51,34 @@ export const questionsQuestionRouter = createProtectedRouter()
},
}
: {}),
+ encounters : {
+ some: {
+ ...(input.companies.length > 0
+ ? {
+ company : {
+ in : input.companies
+ }
+ }
+ : {}),
+ ...(input.locations.length > 0
+ ? {
+ location: {
+ in: input.locations
+ },
+ }
+ : {}),
+ ...(input.roles.length > 0
+ ? {
+ role : {
+ in: input.roles
+ }
+ }
+ : {}),
+ }
+ }
},
});
return questionsData
- .filter((data) => {
- for (let i = 0; i < data.encounters.length; i++) {
- const encounter = data.encounters[i];
- const matchCompany =
- input.companyIds.length === 0 ||
- input.companyIds.includes(encounter.company!.id);
- const matchLocation =
- input.locations.length === 0 ||
- input.locations.includes(encounter.location);
- const matchRole =
- input.roles.length === 0 || input.roles.includes(encounter.role);
- const matchDate =
- (!input.startDate || encounter.seenAt >= input.startDate) &&
- encounter.seenAt <= input.endDate;
- if (matchCompany && matchLocation && matchRole && matchDate) {
- return true;
- }
- }
- return false;
- })
.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
@@ -237,9 +242,13 @@ export const questionsQuestionRouter = createProtectedRouter()
});
}
+ const { content, questionType } = input;
+
return await ctx.prisma.questionsQuestion.update({
+
data: {
- ...input,
+ content,
+ questionType,
},
where: {
id: input.id,
@@ -297,11 +306,13 @@ export const questionsQuestionRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { questionId, vote } = input;
return await ctx.prisma.questionsQuestionVote.create({
data: {
- ...input,
+ questionId,
userId,
+ vote,
},
});
},
@@ -321,7 +332,7 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
- if (voteToUpdate?.id !== userId) {
+ if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@@ -351,7 +362,7 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
- if (voteToDelete?.id !== userId) {
+ if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
diff --git a/apps/portal/src/server/router/resumes/resumes-reviews-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-router.ts
similarity index 73%
rename from apps/portal/src/server/router/resumes/resumes-reviews-router.ts
rename to apps/portal/src/server/router/resumes/resumes-comments-router.ts
index 04672cb2..33d6256a 100644
--- a/apps/portal/src/server/router/resumes/resumes-reviews-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-comments-router.ts
@@ -4,12 +4,11 @@ import { createRouter } from '../context';
import type { ResumeComment } from '~/types/resume-comments';
-export const resumeReviewsRouter = createRouter().query('list', {
+export const resumeCommentsRouter = createRouter().query('list', {
input: z.object({
resumeId: z.string(),
}),
async resolve({ ctx, input }) {
- const userId = ctx.session?.user?.id;
const { resumeId } = input;
// For this resume, we retrieve every comment's information, along with:
@@ -17,23 +16,12 @@ export const resumeReviewsRouter = createRouter().query('list', {
// Number of votes, and whether the user (if-any) has voted
const comments = await ctx.prisma.resumesComment.findMany({
include: {
- _count: {
- select: {
- votes: true,
- },
- },
user: {
select: {
image: true,
name: true,
},
},
- votes: {
- take: 1,
- where: {
- userId,
- },
- },
},
orderBy: {
createdAt: 'desc',
@@ -44,15 +32,10 @@ export const resumeReviewsRouter = createRouter().query('list', {
});
return comments.map((data) => {
- const hasVoted = data.votes.length > 0;
- const numVotes = data._count.votes;
-
const comment: ResumeComment = {
createdAt: data.createdAt,
description: data.description,
- hasVoted,
id: data.id,
- numVotes,
resumeId: data.resumeId,
section: data.section,
updatedAt: data.updatedAt,
diff --git a/apps/portal/src/server/router/resumes/resumes-reviews-user-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts
similarity index 70%
rename from apps/portal/src/server/router/resumes/resumes-reviews-user-router.ts
rename to apps/portal/src/server/router/resumes/resumes-comments-user-router.ts
index 069e5548..94c375f7 100644
--- a/apps/portal/src/server/router/resumes/resumes-reviews-user-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts
@@ -3,16 +3,15 @@ import { ResumesSection } from '@prisma/client';
import { createProtectedRouter } from '../context';
-type IResumeCommentInput = Readonly<{
+type ResumeCommentInput = Readonly<{
description: string;
resumeId: string;
section: ResumesSection;
userId: string;
}>;
-export const resumesReviewsUserRouter = createProtectedRouter().mutation(
- 'create',
- {
+export const resumesCommentsUserRouter = createProtectedRouter()
+ .mutation('create', {
input: z.object({
education: z.string(),
experience: z.string(),
@@ -22,12 +21,12 @@ export const resumesReviewsUserRouter = createProtectedRouter().mutation(
skills: z.string(),
}),
async resolve({ ctx, input }) {
- const userId = ctx.session?.user?.id;
+ const userId = ctx.session.user.id;
const { resumeId, education, experience, general, projects, skills } =
input;
// For each section, convert them into ResumesComment model if provided
- const comments: Array = [
+ const comments: Array = [
{ description: education, section: ResumesSection.EDUCATION },
{ description: experience, section: ResumesSection.EXPERIENCE },
{ description: general, section: ResumesSection.GENERAL },
@@ -50,5 +49,22 @@ export const resumesReviewsUserRouter = createProtectedRouter().mutation(
data: comments,
});
},
- },
-);
+ })
+ .mutation('update', {
+ input: z.object({
+ description: z.string(),
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const { id, description } = input;
+
+ return await ctx.prisma.resumesComment.update({
+ data: {
+ description,
+ },
+ where: {
+ id,
+ },
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts
new file mode 100644
index 00000000..5d508c35
--- /dev/null
+++ b/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod';
+import type { ResumesCommentVote } from '@prisma/client';
+import { Vote } from '@prisma/client';
+
+import { createRouter } from '../context';
+
+import type { ResumeCommentVote } from '~/types/resume-comments';
+
+export const resumesCommentsVotesRouter = createRouter().query('list', {
+ input: z.object({
+ commentId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { commentId } = input;
+
+ const votes = await ctx.prisma.resumesCommentVote.findMany({
+ where: {
+ commentId,
+ },
+ });
+
+ let userVote: ResumesCommentVote | null = null;
+ let numVotes = 0;
+
+ votes.forEach((vote) => {
+ numVotes += vote.value === Vote.UPVOTE ? 1 : -1;
+ userVote = vote.userId === userId ? vote : null;
+ });
+
+ const resumeCommentVote: ResumeCommentVote = {
+ numVotes,
+ userVote,
+ };
+
+ return resumeCommentVote;
+ },
+});
diff --git a/apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts
new file mode 100644
index 00000000..7dbeec77
--- /dev/null
+++ b/apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts
@@ -0,0 +1,45 @@
+import { z } from 'zod';
+import { Vote } from '@prisma/client';
+
+import { createProtectedRouter } from '../context';
+
+export const resumesCommentsVotesUserRouter = createProtectedRouter()
+ .mutation('upsert', {
+ input: z.object({
+ commentId: z.string(),
+ value: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session.user.id;
+ const { commentId, value } = input;
+
+ await ctx.prisma.resumesCommentVote.upsert({
+ create: {
+ commentId,
+ userId,
+ value,
+ },
+ update: {
+ value,
+ },
+ where: {
+ userId_commentId: { commentId, userId },
+ },
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ commentId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session.user.id;
+ const { commentId } = input;
+
+ await ctx.prisma.resumesCommentVote.delete({
+ where: {
+ userId_commentId: { commentId, userId },
+ },
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/resumes/resumes-resume-router.ts b/apps/portal/src/server/router/resumes/resumes-resume-router.ts
index 177b076e..4f5c33d8 100644
--- a/apps/portal/src/server/router/resumes/resumes-resume-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-resume-router.ts
@@ -7,6 +7,7 @@ import type { Resume } from '~/types/resume';
export const resumesRouter = createRouter()
.query('findAll', {
async resolve({ ctx }) {
+ const userId = ctx.session?.user?.id;
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
@@ -15,6 +16,13 @@ export const resumesRouter = createRouter()
stars: true,
},
},
+ stars: {
+ where: {
+ OR: {
+ userId,
+ },
+ },
+ },
user: {
select: {
name: true,
@@ -31,6 +39,7 @@ export const resumesRouter = createRouter()
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
+ isStarredByUser: r.stars.length > 0,
location: r.location,
numComments: r._count.comments,
numStars: r._count.stars,
@@ -62,7 +71,9 @@ export const resumesRouter = createRouter()
},
stars: {
where: {
- userId,
+ OR: {
+ userId,
+ },
},
},
user: {
diff --git a/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts b/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts
index 3b0b5a0b..a61aa3ac 100644
--- a/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts
@@ -5,29 +5,48 @@ import { createProtectedRouter } from '../context';
import type { Resume } from '~/types/resume';
export const resumesResumeUserRouter = createProtectedRouter()
- .mutation('create', {
+ .mutation('upsert', {
// TODO: Use enums for experience, location, role
input: z.object({
additionalInfo: z.string().optional(),
experience: z.string(),
+ id: z.string().optional(),
location: z.string(),
role: z.string(),
title: z.string(),
url: z.string(),
}),
async resolve({ ctx, input }) {
- const userId = ctx.session?.user.id;
- return await ctx.prisma.resumesResume.create({
- data: {
- ...input,
+ const userId = ctx.session.user.id;
+
+ return await ctx.prisma.resumesResume.upsert({
+ create: {
+ additionalInfo: input.additionalInfo,
+ experience: input.experience,
+ location: input.location,
+ role: input.role,
+ title: input.title,
+ url: input.url,
+ userId,
+ },
+ update: {
+ additionalInfo: input.additionalInfo,
+ experience: input.experience,
+ location: input.location,
+ role: input.role,
+ title: input.title,
+ url: input.url,
userId,
},
+ where: {
+ id: input.id ?? '',
+ },
});
},
})
.query('findUserStarred', {
async resolve({ ctx }) {
- const userId = ctx.session?.user?.id;
+ const userId = ctx.session.user.id;
const resumeStarsData = await ctx.prisma.resumesStar.findMany({
include: {
resume: {
@@ -59,6 +78,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
createdAt: rs.resume.createdAt,
experience: rs.resume.experience,
id: rs.resume.id,
+ isStarredByUser: true,
location: rs.resume.location,
numComments: rs.resume._count.comments,
numStars: rs.resume._count.stars,
@@ -73,7 +93,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
})
.query('findUserCreated', {
async resolve({ ctx }) {
- const userId = ctx.session?.user?.id;
+ const userId = ctx.session.user.id;
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
@@ -82,6 +102,11 @@ export const resumesResumeUserRouter = createProtectedRouter()
stars: true,
},
},
+ stars: {
+ where: {
+ userId,
+ },
+ },
user: {
select: {
name: true,
@@ -101,6 +126,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
+ isStarredByUser: r.stars.length > 0,
location: r.location,
numComments: r._count.comments,
numStars: r._count.stars,
diff --git a/apps/portal/src/server/router/resumes/resumes-star-user-router.ts b/apps/portal/src/server/router/resumes/resumes-star-user-router.ts
index 40daea7d..9e780858 100644
--- a/apps/portal/src/server/router/resumes/resumes-star-user-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-star-user-router.ts
@@ -2,22 +2,15 @@ import { z } from 'zod';
import { createProtectedRouter } from '../context';
-export const resumesStarUserRouter = createProtectedRouter().mutation(
- 'create_or_delete',
- {
+export const resumesStarUserRouter = createProtectedRouter()
+ .mutation('unstar', {
input: z.object({
resumeId: z.string(),
}),
async resolve({ ctx, input }) {
const { resumeId } = input;
- // Update_star will only be called if user is logged in
- const userId = ctx.session!.user!.id;
-
- // Use the resumeId and resumeProfileId to check if star exists
- const resumesStar = await ctx.prisma.resumesStar.findUnique({
- select: {
- id: true,
- },
+ const userId = ctx.session.user.id;
+ return await ctx.prisma.resumesStar.delete({
where: {
userId_resumeId: {
resumeId,
@@ -25,23 +18,20 @@ export const resumesStarUserRouter = createProtectedRouter().mutation(
},
},
});
-
- if (resumesStar === null) {
- return await ctx.prisma.resumesStar.create({
- data: {
- resumeId,
- userId,
- },
- });
- }
- return await ctx.prisma.resumesStar.delete({
- where: {
- userId_resumeId: {
- resumeId,
- userId,
- },
+ },
+ })
+ .mutation('star', {
+ input: z.object({
+ resumeId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const { resumeId } = input;
+ const userId = ctx.session.user.id;
+ return await ctx.prisma.resumesStar.create({
+ data: {
+ resumeId,
+ userId,
},
});
},
- },
-);
+ });
diff --git a/apps/portal/src/types/offers.d.ts b/apps/portal/src/types/offers.d.ts
new file mode 100644
index 00000000..35539b45
--- /dev/null
+++ b/apps/portal/src/types/offers.d.ts
@@ -0,0 +1,186 @@
+import type { JobType } from '@prisma/client';
+
+export type Profile = {
+ analysis: ProfileAnalysis?;
+ background: Background?;
+ editToken: string?;
+ id: string;
+ isEditable: boolean;
+ offers: Array;
+ profileName: string;
+};
+
+export type Background = {
+ educations: Array;
+ experiences: Array;
+ id: string;
+ specificYoes: Array;
+ totalYoe: number;
+};
+
+export type Experience = {
+ company: OffersCompany?;
+ durationInMonths: number?;
+ id: string;
+ jobType: JobType?;
+ level: string?;
+ monthlySalary: Valuation?;
+ specialization: string?;
+ title: string?;
+ totalCompensation: Valuation?;
+};
+
+export type OffersCompany = {
+ createdAt: Date;
+ description: string;
+ id: string;
+ logoUrl: string;
+ name: string;
+ slug: string;
+ updatedAt: Date;
+};
+
+export type Valuation = {
+ currency: string;
+ value: number;
+};
+
+export type Education = {
+ endDate: Date?;
+ field: string?;
+ id: string;
+ school: string?;
+ startDate: Date?;
+ type: string?;
+};
+
+export type SpecificYoe = {
+ domain: string;
+ id: string;
+ yoe: number;
+};
+
+export type DashboardOffer = {
+ company: OffersCompany;
+ id: string;
+ income: Valuation;
+ monthYearReceived: Date;
+ profileId: string;
+ title: string;
+ totalYoe: number;
+};
+
+export type ProfileOffer = {
+ comments: string;
+ company: OffersCompany;
+ id: string;
+ jobType: JobType;
+ location: string;
+ monthYearReceived: Date;
+ negotiationStrategy: string;
+ offersFullTime: FullTime?;
+ offersIntern: Intern?;
+};
+
+export type FullTime = {
+ baseSalary: Valuation;
+ bonus: Valuation;
+ id: string;
+ level: string;
+ specialization: string;
+ stocks: Valuation;
+ title: string;
+ totalCompensation: Valuation;
+};
+
+export type Intern = {
+ id: string;
+ internshipCycle: string;
+ monthlySalary: Valuation;
+ specialization: string;
+ startYear: number;
+ title: string;
+};
+
+export type Reply = {
+ createdAt: Date;
+ id: string;
+ message: string;
+ replies: Array?;
+ replyingToId: string?;
+ user: User?;
+};
+
+export type User = {
+ email: string?;
+ emailVerified: Date?;
+ id: string;
+ image: string?;
+ name: string?;
+};
+
+export type GetOffersResponse = {
+ data: Array;
+ paging: Paging;
+};
+
+export type Paging = {
+ currentPage: number;
+ numOfItems: number;
+ numOfPages: number;
+ totalItems: number;
+};
+
+export type CreateOfferProfileResponse = {
+ id: string;
+ token: string;
+};
+
+export type OffersDiscussion = {
+ data: Array;
+};
+
+export type ProfileAnalysis = {
+ companyAnalysis: Array;
+ id: string;
+ overallAnalysis: Analysis;
+ overallHighestOffer: AnalysisHighestOffer;
+ profileId: string;
+};
+
+export type Analysis = {
+ noOfOffers: number;
+ percentile: number;
+ topPercentileOffers: Array;
+};
+
+export type AnalysisHighestOffer = {
+ company: OffersCompany;
+ id: string;
+ level: string;
+ location: string;
+ specialization: string;
+ totalYoe: number;
+};
+
+export type AnalysisOffer = {
+ company: OffersCompany;
+ id: string;
+ income: number;
+ jobType: JobType;
+ level: string;
+ location: string;
+ monthYearReceived: Date;
+ negotiationStrategy: string;
+ previousCompanies: Array;
+ profileName: string;
+ specialization: string;
+ title: string;
+ totalYoe: number;
+};
+
+export type AddToProfileResponse = {
+ id: string;
+ profileName: string;
+ userId: string;
+};
diff --git a/apps/portal/src/types/questions.d.ts b/apps/portal/src/types/questions.d.ts
index 90bb8b32..9ee7d6f0 100644
--- a/apps/portal/src/types/questions.d.ts
+++ b/apps/portal/src/types/questions.d.ts
@@ -23,6 +23,7 @@ export type AnswerComment = {
numVotes: number;
updatedAt: Date;
user: string;
+ userImage: string;
};
export type Answer = {
@@ -32,6 +33,7 @@ export type Answer = {
numComments: number;
numVotes: number;
user: string;
+ userImage: string;
};
export type QuestionComment = {
@@ -40,4 +42,5 @@ export type QuestionComment = {
id: string;
numVotes: number;
user: string;
+ userImage: string;
};
diff --git a/apps/portal/src/types/resume-comments.d.ts b/apps/portal/src/types/resume-comments.d.ts
index 5a6dfff8..335948c3 100644
--- a/apps/portal/src/types/resume-comments.d.ts
+++ b/apps/portal/src/types/resume-comments.d.ts
@@ -1,15 +1,13 @@
-import type { ResumesSection } from '@prisma/client';
+import type { ResumesCommentVote, ResumesSection } from '@prisma/client';
/**
- * Returned by `resumeReviewsRouter` (query for 'resumes.reviews.list') and received as prop by `Comment` in `CommentsList`
+ * Returned by `resumeCommentsRouter` (query for 'resumes.comments.list') and received as prop by `Comment` in `CommentsList`
* frontend-friendly representation of the query
*/
-export type ResumeComment = {
+export type ResumeComment = Readonly<{
createdAt: Date;
description: string;
- hasVoted: boolean;
id: string;
- numVotes: number;
resumeId: string;
section: ResumesSection;
updatedAt: Date;
@@ -18,4 +16,9 @@ export type ResumeComment = {
name: string?;
userId: string;
};
-};
+}>;
+
+export type ResumeCommentVote = Readonly<{
+ numVotes: number;
+ userVote: ResumesCommentVote?;
+}>;
diff --git a/apps/portal/src/types/resume.d.ts b/apps/portal/src/types/resume.d.ts
index 5b2a33a9..39e782bb 100644
--- a/apps/portal/src/types/resume.d.ts
+++ b/apps/portal/src/types/resume.d.ts
@@ -3,6 +3,7 @@ export type Resume = {
createdAt: Date;
experience: string;
id: string;
+ isStarredByUser: boolean;
location: string;
numComments: number;
numStars: number;
diff --git a/apps/portal/src/utils/JobExperienceLevel.ts b/apps/portal/src/utils/JobExperienceLevel.ts
new file mode 100644
index 00000000..3c45001b
--- /dev/null
+++ b/apps/portal/src/utils/JobExperienceLevel.ts
@@ -0,0 +1,29 @@
+enum JobExperienceLevel {
+ Entry,
+ Mid,
+ Senior,
+}
+
+export function yearsOfExperienceToLevel(years: number): Readonly<{
+ label: string;
+ level: JobExperienceLevel;
+}> {
+ if (years <= 2) {
+ return {
+ label: 'Entry Level',
+ level: JobExperienceLevel.Entry,
+ };
+ }
+
+ if (years <= 5) {
+ return {
+ label: 'Mid Level',
+ level: JobExperienceLevel.Mid,
+ };
+ }
+
+ return {
+ label: 'Senior Level',
+ level: JobExperienceLevel.Senior,
+ };
+}
diff --git a/apps/portal/src/components/offers/util/currency/CurrencyEnum.tsx b/apps/portal/src/utils/offers/currency/CurrencyEnum.tsx
similarity index 100%
rename from apps/portal/src/components/offers/util/currency/CurrencyEnum.tsx
rename to apps/portal/src/utils/offers/currency/CurrencyEnum.tsx
diff --git a/apps/portal/src/components/offers/util/currency/CurrencySelector.tsx b/apps/portal/src/utils/offers/currency/CurrencySelector.tsx
similarity index 89%
rename from apps/portal/src/components/offers/util/currency/CurrencySelector.tsx
rename to apps/portal/src/utils/offers/currency/CurrencySelector.tsx
index 2ebe5bd5..2ba883b4 100644
--- a/apps/portal/src/components/offers/util/currency/CurrencySelector.tsx
+++ b/apps/portal/src/utils/offers/currency/CurrencySelector.tsx
@@ -1,6 +1,6 @@
import { Select } from '@tih/ui';
-import { Currency } from '~/components/offers/util/currency/CurrencyEnum';
+import { Currency } from '~/utils/offers/currency/CurrencyEnum';
const currencyOptions = Object.entries(Currency).map(([key, value]) => ({
label: key,
diff --git a/apps/portal/src/utils/offers/currency/index.tsx b/apps/portal/src/utils/offers/currency/index.tsx
new file mode 100644
index 00000000..c2cfcb05
--- /dev/null
+++ b/apps/portal/src/utils/offers/currency/index.tsx
@@ -0,0 +1,14 @@
+import type { Money } from '~/components/offers/types';
+
+export function convertCurrencyToString({ currency, value }: Money) {
+ if (!value) {
+ return '-';
+ }
+ const formatter = new Intl.NumberFormat('en-US', {
+ currency,
+ maximumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1)
+ minimumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
+ style: 'currency',
+ });
+ return `${formatter.format(10000)}`; /* $2,500.00 */
+}
diff --git a/apps/portal/src/utils/offers/form.tsx b/apps/portal/src/utils/offers/form.tsx
new file mode 100644
index 00000000..2e88ac88
--- /dev/null
+++ b/apps/portal/src/utils/offers/form.tsx
@@ -0,0 +1,60 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/**
+ * Removes empty objects, empty strings, `null`, `undefined`, and `NaN` values from an object.
+ * Does not remove empty arrays.
+ * @param object
+ * @returns object without empty values or objects.
+ */
+export function cleanObject(object: any) {
+ Object.entries(object).forEach(([k, v]) => {
+ if ((v && typeof v === 'object') || Array.isArray(v)) {
+ cleanObject(v);
+ }
+ if (
+ (v &&
+ typeof v === 'object' &&
+ !Object.keys(v).length &&
+ !Array.isArray(v)) ||
+ v === null ||
+ v === undefined ||
+ v === '' ||
+ v !== v
+ ) {
+ if (Array.isArray(object)) {
+ const index = object.indexOf(v);
+ object.splice(index, 1);
+ } else if (!(v instanceof Date)) {
+ delete object[k];
+ }
+ }
+ });
+ return object;
+}
+
+/**
+ * Removes invalid money data from an object.
+ * If currency is present but value is not present, money object is removed.
+ * @param object
+ * @returns object without invalid money data.
+ */
+export function removeInvalidMoneyData(object: any) {
+ Object.entries(object).forEach(([k, v]) => {
+ if ((v && typeof v === 'object') || Array.isArray(v)) {
+ removeInvalidMoneyData(v);
+ }
+ if (k === 'currency') {
+ if (object.value === undefined) {
+ delete object[k];
+ } else if (
+ object.value === null ||
+ object.value !== object.value ||
+ object.value === ''
+ ) {
+ delete object[k];
+ delete object.value;
+ }
+ }
+ });
+ return object;
+}
diff --git a/apps/portal/src/utils/offers/time.tsx b/apps/portal/src/utils/offers/time.tsx
new file mode 100644
index 00000000..c13a6efe
--- /dev/null
+++ b/apps/portal/src/utils/offers/time.tsx
@@ -0,0 +1,25 @@
+import { getMonth, getYear } from 'date-fns';
+
+import type { MonthYear } from '~/components/shared/MonthYearPicker';
+
+export function formatDate(value: Date | number | string) {
+ const date = new Date(value);
+ // Const day = date.toLocaleString('default', { day: '2-digit' });
+ const month = date.toLocaleString('default', { month: 'short' });
+ const year = date.toLocaleString('default', { year: 'numeric' });
+ return `${month} ${year}`;
+}
+
+export function formatMonthYear({ month, year }: MonthYear) {
+ const monthString = month < 10 ? month.toString() : `0${month}`;
+ const yearString = year.toString();
+ return `${monthString}/${yearString}`;
+}
+
+export function getCurrentMonth() {
+ return getMonth(Date.now());
+}
+
+export function getCurrentYear() {
+ return getYear(Date.now());
+}
diff --git a/apps/portal/src/utils/questions/useSearchFilter.ts b/apps/portal/src/utils/questions/useSearchFilter.ts
index 1a7bd199..0b916261 100644
--- a/apps/portal/src/utils/questions/useSearchFilter.ts
+++ b/apps/portal/src/utils/questions/useSearchFilter.ts
@@ -27,13 +27,6 @@ export const useSearchFilter = (
if (localStorageValue !== null) {
const loadedFilters = JSON.parse(localStorageValue);
setFilters(loadedFilters);
- router.replace({
- pathname: router.pathname,
- query: {
- ...router.query,
- [name]: loadedFilters,
- },
- });
}
}
setIsInitialized(true);
@@ -44,15 +37,8 @@ export const useSearchFilter = (
(newFilters: Array) => {
setFilters(newFilters);
localStorage.setItem(name, JSON.stringify(newFilters));
- router.replace({
- pathname: router.pathname,
- query: {
- ...router.query,
- [name]: newFilters,
- },
- });
},
- [name, router],
+ [name],
);
return [filters, setFiltersCallback, isInitialized] as const;
@@ -73,9 +59,7 @@ export const useSearchFilterSingle = (
return [
filters[0],
- (value: Value) => {
- setFilters([value]);
- },
+ (value: Value) => setFilters([value]),
isInitialized,
] as const;
};
diff --git a/apps/portal/src/utils/questions/useVote.ts b/apps/portal/src/utils/questions/useVote.ts
new file mode 100644
index 00000000..e71a6a62
--- /dev/null
+++ b/apps/portal/src/utils/questions/useVote.ts
@@ -0,0 +1,175 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { useCallback } from 'react';
+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;
+};
+
+type BackendVote = {
+ id: string;
+ vote: Vote;
+};
+
+const createVoteCallbacks = (
+ vote: BackendVote | null,
+ opts: UseVoteOptions,
+) => {
+ const { createVote, updateVote, deleteVote } = 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
+ } else {
+ createVote({
+ vote: 'UPVOTE',
+ });
+ }
+ };
+
+ 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
+ } else {
+ createVote({
+ vote: 'DOWNVOTE',
+ });
+ }
+ };
+
+ return { handleDownvote, handleUpvote };
+};
+
+type MutationKey = Parameters[0];
+type QueryKey = Parameters[0][0];
+
+export const useQuestionVote = (id: string) => {
+ return useVote(id, {
+ create: 'questions.questions.createVote',
+ deleteKey: 'questions.questions.deleteVote',
+ idKey: 'questionId',
+ query: 'questions.questions.getVote',
+ update: 'questions.questions.updateVote',
+ });
+};
+
+export const useAnswerVote = (id: string) => {
+ return useVote(id, {
+ create: 'questions.answers.createVote',
+ deleteKey: 'questions.answers.deleteVote',
+ idKey: 'answerId',
+ query: 'questions.answers.getVote',
+ update: 'questions.answers.updateVote',
+ });
+};
+
+export const useQuestionCommentVote = (id: string) => {
+ return useVote(id, {
+ create: 'questions.questions.comments.createVote',
+ deleteKey: 'questions.questions.comments.deleteVote',
+ idKey: 'questionCommentId',
+ query: 'questions.questions.comments.getVote',
+ update: 'questions.questions.comments.updateVote',
+ });
+};
+
+export const useAnswerCommentVote = (id: string) => {
+ return useVote(id, {
+ create: 'questions.answers.comments.createVote',
+ deleteKey: 'questions.answers.comments.deleteVote',
+ idKey: 'answerCommentId',
+ query: 'questions.answers.comments.getVote',
+ update: 'questions.answers.comments.updateVote',
+ });
+};
+
+type VoteProps = {
+ create: MutationKey;
+ deleteKey: MutationKey;
+ idKey: string;
+ query: VoteQueryKey;
+ update: MutationKey;
+};
+
+export const useVote = (
+ id: string,
+ opts: VoteProps,
+) => {
+ const { create, deleteKey, query, update, idKey } = opts;
+ const utils = trpc.useContext();
+
+ const onVoteUpdate = useCallback(() => {
+ // TODO: Optimise query invalidation
+ utils.invalidateQueries([query, { [idKey]: id } as any]);
+ utils.invalidateQueries(['questions.questions.getQuestionsByFilter']);
+ utils.invalidateQueries(['questions.questions.getQuestionById']);
+ utils.invalidateQueries(['questions.answers.getAnswers']);
+ utils.invalidateQueries(['questions.answers.getAnswerById']);
+ utils.invalidateQueries([
+ 'questions.questions.comments.getQuestionComments',
+ ]);
+ utils.invalidateQueries(['questions.answers.comments.getAnswerComments']);
+ }, [id, idKey, utils, query]);
+
+ const { data } = trpc.useQuery([
+ query,
+ {
+ [idKey]: id,
+ },
+ ] as any);
+
+ const backendVote = data as BackendVote;
+
+ const { mutate: createVote } = trpc.useMutation(create, {
+ onSuccess: onVoteUpdate,
+ });
+ const { mutate: updateVote } = trpc.useMutation(update, {
+ onSuccess: onVoteUpdate,
+ });
+
+ const { mutate: deleteVote } = trpc.useMutation(deleteKey, {
+ onSuccess: onVoteUpdate,
+ });
+
+ const { handleDownvote, handleUpvote } = createVoteCallbacks(
+ backendVote ?? null,
+ {
+ createVote: ({ vote }) => {
+ createVote({
+ [idKey]: id,
+ vote,
+ } as any);
+ },
+ deleteVote,
+ updateVote,
+ },
+ );
+
+ return { handleDownvote, handleUpvote, vote: backendVote ?? null };
+};
diff --git a/apps/storybook/package.json b/apps/storybook/package.json
index df648014..546cf92c 100644
--- a/apps/storybook/package.json
+++ b/apps/storybook/package.json
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "start-storybook -p 6001",
- "build": "build-storybook --docs",
+ "build": "tsc && build-storybook --docs",
"preview-storybook": "serve storybook-static",
"clean": "rm -rf .turbo && rm -rf node_modules",
"lint": "eslint stories/**/*.ts* --fix",
diff --git a/apps/storybook/stories/select.stories.tsx b/apps/storybook/stories/select.stories.tsx
index 35c74362..7cfe1187 100644
--- a/apps/storybook/stories/select.stories.tsx
+++ b/apps/storybook/stories/select.stories.tsx
@@ -14,6 +14,9 @@ export default {
control: { type: 'select' },
options: SelectDisplays,
},
+ errorMessage: {
+ control: 'text',
+ },
isLabelHidden: {
control: 'boolean',
},
@@ -23,6 +26,9 @@ export default {
name: {
control: 'text',
},
+ placeholder: {
+ control: 'text',
+ },
},
component: Select,
title: 'Select',
@@ -181,28 +187,78 @@ export function Required() {
const [value, setValue] = useState('apple');
return (
-
-
-
+
+ );
+}
+
+export function Placeholder() {
+ return (
+
+ );
+}
+
+export function Error() {
+ const [value, setValue] = useState('banana');
+
+ return (
+
);
}
diff --git a/apps/storybook/stories/typeahead.stories.tsx b/apps/storybook/stories/typeahead.stories.tsx
index a0de94d8..defffbf4 100644
--- a/apps/storybook/stories/typeahead.stories.tsx
+++ b/apps/storybook/stories/typeahead.stories.tsx
@@ -17,6 +17,12 @@ export default {
noResultsMessage: {
control: 'text',
},
+ placeholder: {
+ control: 'text',
+ },
+ required: {
+ control: 'boolean',
+ },
},
component: Typeahead,
parameters: {
@@ -77,3 +83,39 @@ Basic.args = {
isLabelHidden: false,
label: 'Author',
};
+
+export function Required() {
+ const people = [
+ { id: '1', label: 'Wade Cooper', value: '1' },
+ { id: '2', label: 'Arlene Mccoy', value: '2' },
+ { id: '3', label: 'Devon Webb', value: '3' },
+ { id: '4', label: 'Tom Cook', value: '4' },
+ { id: '5', label: 'Tanya Fox', value: '5' },
+ { id: '6', label: 'Hellen Schmidt', value: '6' },
+ ];
+ const [selectedEntry, setSelectedEntry] = useState(
+ people[0],
+ );
+ const [query, setQuery] = useState('');
+
+ const filteredPeople =
+ query === ''
+ ? people
+ : people.filter((person) =>
+ person.label
+ .toLowerCase()
+ .replace(/\s+/g, '')
+ .includes(query.toLowerCase().replace(/\s+/g, '')),
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/website/contents/choosing-between-companies.md b/apps/website/contents/choosing-between-companies.md
index 47801ad1..c00e97d7 100644
--- a/apps/website/contents/choosing-between-companies.md
+++ b/apps/website/contents/choosing-between-companies.md
@@ -15,7 +15,7 @@ First and foremost, compensation. Most technical roles at tech companies would r
Not all stock grants are equal as well. Some companies have linear vesting cycles (you vest the same amount every year), some companies like Amazon and Snap have backloaded schemes (you vest less in the earlier years, more later), and there are pay attention to cliffs as well. [Stripe and Lyft](https://www.theinformation.com/articles/stripe-and-lyft-speed-up-equity-payouts-to-first-year) recently changed their stock structure and announced that they will speed up equity payouts to the first year. This sounds good initially, [but in reality there are some nuances](https://tanay.substack.com/p/employee-compensation-and-one-year).
-Regardless of company, **always negotiate** your offer, especially if you have multiple offers to choose from! Having multiple offers in hand is the best bargaining chip you can have for negotiation and you should leverage it. We go into this more in the [Negotiation](./negotiation.md) section. Use [Moonchaser](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_choosing_between_companies) for risk-free negotiation services.
+Regardless of company, **always negotiate** your offer, especially if you have multiple offers to choose from! Having multiple offers in hand is the best bargaining chip you can have for negotiation and you should leverage it. We go into this more in the [Negotiation](./negotiation.md) section. Use [Rora](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_choosing_between_companies) for risk-free negotiation services.
## Products
diff --git a/apps/website/contents/negotiation-rules.md b/apps/website/contents/negotiation-rules.md
index 8f8d59e6..f1deb8f4 100644
--- a/apps/website/contents/negotiation-rules.md
+++ b/apps/website/contents/negotiation-rules.md
@@ -110,6 +110,6 @@ Don't waste their time or play games for your own purposes. Even if the company
:::tip Expert tip
-Get paid more. Receive salary negotiation help from [**Moonchaser**](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
+Get paid more. Receive salary negotiation help from [**Rora**](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
:::
diff --git a/apps/website/contents/negotiation.md b/apps/website/contents/negotiation.md
index 57fefabd..d98a6abd 100644
--- a/apps/website/contents/negotiation.md
+++ b/apps/website/contents/negotiation.md
@@ -32,11 +32,11 @@ If you've received an offer (or even better, offers), congratulations! You may h
If you haven't been negotiating your past offers, or are new to the negotiation game, worry not! There are multiple negotiation services that can help you out. Typically, they'd be well-worth the cost. Had I know about negotiation services in the past, I'd have leveraged them!
-### Moonchaser
+### Rora
-How Moonchaser works is that you will be guided by their experienced team of professionals throughout the entire salary negotiation process. It's also risk-free because you don't have to pay anything unless you have an increased offer. It's a **no-brainer decision** to get the help of Moonchaser during the offer process - some increase is better than no increase. Don't leave money on the table! Check out [Moonchaser](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation).
+How Rora works is that you will be guided by their experienced team of professionals throughout the entire salary negotiation process. It's also risk-free because you don't have to pay anything unless you have an increased offer. It's a **no-brainer decision** to get the help of Rora during the offer process - some increase is better than no increase. Don't leave money on the table! Check out [Rora](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation).
-Things Moonchaser can do for you:
+Things Rora can do for you:
- Help you to negotiate increases even without competing offers
- Provide tailored advice through their knowledge of compensation ranges at many companies
@@ -45,18 +45,18 @@ Things Moonchaser can do for you:
- Provide you with live guidance during the recruiter call through chat
- Introduce you to recruiters at other companies
-Book a free consultation with Moonchaser →
+Book a free consultation with Rora →
### Levels.fyi
-[Levels.fyi](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) is most famously known for being a salary database but they also offer complementary services such as salary negotiation where you will be put in-touch with experienced recruiters to help you in the process. How Levels.fyi differs from Moonchaser is that Levels.fyi charges a flat fee whereas Moonchaser takes a percentage of the negotiated difference.
+[Levels.fyi](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) is most famously known for being a salary database but they also offer complementary services such as salary negotiation where you will be put in-touch with experienced recruiters to help you in the process. How Levels.fyi differs from Rora is that Levels.fyi charges a flat fee whereas Rora takes a percentage of the negotiated difference.
:::tip Expert tip
-Get paid more. Receive salary negotiation advice from [**Moonchaser**](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
+Get paid more. Receive salary negotiation advice from [**Rora**](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
:::
diff --git a/apps/website/src/components/SidebarAd/index.js b/apps/website/src/components/SidebarAd/index.js
index 711910df..0e250e20 100644
--- a/apps/website/src/components/SidebarAd/index.js
+++ b/apps/website/src/components/SidebarAd/index.js
@@ -69,23 +69,23 @@ function AlgoMonster({ position }) {
);
}
-function Moonchaser({ position }) {
+function Rora({ position }) {
return (
{
- window.gtag('event', `moonchaser.${position}.click`);
+ window.gtag('event', `rora.${position}.click`);
}}>
Risk-free salary negotiation help
{' '}
- Receive risk-free salary negotiation advice from Moonchaser . You
- pay nothing unless your offer is increased.{' '}
+ Receive risk-free salary negotiation advice from Rora . You pay
+ nothing unless your offer is increased.{' '}
Book your free consultation today!
@@ -210,7 +210,7 @@ export default React.memo(function SidebarAd({ position }) {
}
if (path.includes('negotiation') || path.includes('compensation')) {
- return ;
+ return ;
}
if (path.includes('system-design')) {
diff --git a/apps/website/src/components/SidebarAd/styles.module.css b/apps/website/src/components/SidebarAd/styles.module.css
index d7fc116e..dded48cc 100644
--- a/apps/website/src/components/SidebarAd/styles.module.css
+++ b/apps/website/src/components/SidebarAd/styles.module.css
@@ -37,7 +37,7 @@
background-color: #58527b;
}
-.backgroundMoonchaser {
+.backgroundRora {
background-color: #1574f9;
}
diff --git a/apps/website/src/pages/index.js b/apps/website/src/pages/index.js
index 858d4a4f..0da57814 100755
--- a/apps/website/src/pages/index.js
+++ b/apps/website/src/pages/index.js
@@ -222,7 +222,7 @@ function WhatIsThisSection() {
);
}
-function MoonchaserSection() {
+function RoraSection() {
// Because the SSR and client output can differ and hydration doesn't patch attribute differences,
// we'll render this on the browser only.
return (
@@ -237,18 +237,18 @@ function MoonchaserSection() {
Get paid more. Receive risk-free salary negotiation
- advice from Moonchaser. You pay nothing unless your
- offer is increased.
+ advice from Rora. You pay nothing unless your offer is
+ increased.
{
- window.gtag('event', 'moonchaser.homepage.click');
+ window.gtag('event', 'rora.homepage.click');
}}>
Get risk-free negotiation advice →
@@ -504,7 +504,7 @@ function GreatFrontEndSection() {
return (
+ style={{ backgroundColor: 'rgb(79, 70, 229)' }}>
@@ -517,13 +517,13 @@ function GreatFrontEndSection() {
+ style={{ fontSize: 'var(--ifm-h2-font-size)' }}>
Spend less time but prepare better for your Front End
Interviews with{' '}
+ style={{ color: '#fff', textDecoration: 'underline' }}>
Great Front End's
{' '}
large pool of high quality practice questions and solutions.
diff --git a/package.json b/package.json
index b2b855c5..4c0a4f70 100755
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"dev:ui": "turbo dev --filter=storybook... --filter=ui...",
"dev:website": "turbo dev --filter=website...",
"dev:all": "turbo dev --no-cache --parallel --continue",
- "format": "prettier --write \"**/*.{ts,tsx,md}\"",
+ "format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,md,mdx}\"",
"lint": "turbo lint",
"test": "turbo test",
"tsc": "turbo tsc"
diff --git a/packages/eslint-config-tih/index.js b/packages/eslint-config-tih/index.js
index 8ee2fa69..3894879c 100644
--- a/packages/eslint-config-tih/index.js
+++ b/packages/eslint-config-tih/index.js
@@ -43,7 +43,7 @@ module.exports = {
'no-else-return': [ERROR, { allowElseIf: false }],
'no-extra-boolean-cast': ERROR,
'no-lonely-if': ERROR,
- 'no-shadow': ERROR,
+ 'no-shadow': OFF,
'no-unused-vars': OFF, // Use @typescript-eslint/no-unused-vars instead.
'object-shorthand': ERROR,
'one-var': [ERROR, 'never'],
@@ -100,6 +100,7 @@ module.exports = {
'@typescript-eslint/no-for-in-array': ERROR,
'@typescript-eslint/no-non-null-assertion': OFF,
'@typescript-eslint/no-unused-vars': [ERROR, { argsIgnorePattern: '^_' }],
+ '@typescript-eslint/no-shadow': ERROR,
'@typescript-eslint/prefer-optional-chain': ERROR,
'@typescript-eslint/require-array-sort-compare': ERROR,
'@typescript-eslint/restrict-plus-operands': ERROR,
diff --git a/packages/ui/src/Select/Select.tsx b/packages/ui/src/Select/Select.tsx
index d3af3e00..d23935a9 100644
--- a/packages/ui/src/Select/Select.tsx
+++ b/packages/ui/src/Select/Select.tsx
@@ -20,11 +20,13 @@ type Props = Readonly<{
borderStyle?: SelectBorderStyle;
defaultValue?: T;
display?: SelectDisplay;
+ errorMessage?: string;
isLabelHidden?: boolean;
label: string;
name?: string;
onChange?: (value: string) => void;
options: ReadonlyArray>;
+ placeholder?: string;
value?: T;
}> &
Readonly;
@@ -34,15 +36,25 @@ const borderClasses: Record = {
borderless: 'border-transparent bg-transparent',
};
+type State = 'error' | 'normal';
+
+const stateClasses: Record = {
+ error:
+ 'border-danger-300 text-danger-900 placeholder-danger-300 focus:outline-none focus:ring-danger-500 focus:border-danger-500',
+ normal: 'focus:border-primary-500 focus:ring-primary-500',
+};
+
function Select(
{
borderStyle = 'bordered',
defaultValue,
display,
disabled,
+ errorMessage,
label,
isLabelHidden,
options,
+ placeholder,
required,
value,
onChange,
@@ -50,7 +62,10 @@ function Select(
}: Props,
ref: ForwardedRef,
) {
+ const hasError = errorMessage != null;
const id = useId();
+ const errorId = useId();
+ const state: State = hasError ? 'error' : 'normal';
return (
@@ -69,10 +84,12 @@ function Select
(
)}
(
onChange?.(event.target.value);
}}
{...props}>
+ {placeholder && (
+
+ {placeholder}
+
+ )}
{options.map(({ label: optionLabel, value: optionValue }) => (
{optionLabel}
))}
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
);
}
diff --git a/packages/ui/src/SlideOut/SlideOut.tsx b/packages/ui/src/SlideOut/SlideOut.tsx
index 065c60dc..33dd456a 100644
--- a/packages/ui/src/SlideOut/SlideOut.tsx
+++ b/packages/ui/src/SlideOut/SlideOut.tsx
@@ -8,7 +8,7 @@ export type SlideOutEnterFrom = 'end' | 'start';
type Props = Readonly<{
children: React.ReactNode;
- className: string;
+ className?: string;
enterFrom?: SlideOutEnterFrom;
isShown?: boolean;
onClose?: () => void;
diff --git a/packages/ui/src/Typeahead/Typeahead.tsx b/packages/ui/src/Typeahead/Typeahead.tsx
index 2197716d..e84d03a3 100644
--- a/packages/ui/src/Typeahead/Typeahead.tsx
+++ b/packages/ui/src/Typeahead/Typeahead.tsx
@@ -1,7 +1,8 @@
import clsx from 'clsx';
+import type { InputHTMLAttributes } from 'react';
import { Fragment, useState } from 'react';
import { Combobox, Transition } from '@headlessui/react';
-import { ChevronUpDownIcon } from '@heroicons/react/20/solid';
+import { ChevronDownIcon } from '@heroicons/react/20/solid';
export type TypeaheadOption = Readonly<{
// String value to uniquely identify the option.
@@ -10,8 +11,18 @@ export type TypeaheadOption = Readonly<{
value: string;
}>;
+type Attributes = Pick<
+ InputHTMLAttributes,
+ | 'disabled'
+ | 'name'
+ | 'onBlur'
+ | 'onFocus'
+ | 'pattern'
+ | 'placeholder'
+ | 'required'
+>;
+
type Props = Readonly<{
- disabled?: boolean;
isLabelHidden?: boolean;
label: string;
noResultsMessage?: string;
@@ -23,7 +34,8 @@ type Props = Readonly<{
onSelect: (option: TypeaheadOption) => void;
options: ReadonlyArray;
value?: TypeaheadOption;
-}>;
+}> &
+ Readonly;
export default function Typeahead({
disabled = false,
@@ -33,8 +45,10 @@ export default function Typeahead({
nullable = false,
options,
onQueryChange,
+ required,
value,
onSelect,
+ ...props
}: Props) {
const [query, setQuery] = useState('');
return (
@@ -66,6 +80,12 @@ export default function Typeahead({
: 'mb-1 block text-sm font-medium text-slate-700',
)}>
{label}
+ {required && (
+
+ {' '}
+ *
+
+ )}
@@ -77,13 +97,15 @@ export default function Typeahead({
displayValue={(option) =>
(option as unknown as TypeaheadOption)?.label
}
+ required={required}
onChange={(event) => {
setQuery(event.target.value);
onQueryChange(event.target.value, event);
}}
+ {...props}
/>
-
diff --git a/yarn.lock b/yarn.lock
index f4b091d4..bfce8e7c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4605,6 +4605,11 @@ atob@^2.1.2:
resolved "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz"
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
+attr-accept@^2.2.2:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
+ integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
+
autoprefixer@^10.3.7, autoprefixer@^10.4.12, autoprefixer@^10.4.7:
version "10.4.12"
resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.12.tgz"
@@ -7733,6 +7738,13 @@ file-loader@^6.0.0, file-loader@^6.2.0:
loader-utils "^2.0.0"
schema-utils "^3.0.0"
+file-selector@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc"
+ integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==
+ dependencies:
+ tslib "^2.4.0"
+
file-system-cache@^1.0.5:
version "1.1.0"
resolved "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.1.0.tgz"
@@ -12162,6 +12174,15 @@ react-dom@18.2.0, react-dom@^18.2.0:
loose-envify "^1.1.0"
scheduler "^0.23.0"
+react-dropzone@^14.2.3:
+ version "14.2.3"
+ resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b"
+ integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==
+ dependencies:
+ attr-accept "^2.2.2"
+ file-selector "^0.6.0"
+ prop-types "^15.8.1"
+
react-element-to-jsx-string@^14.3.4:
version "14.3.4"
resolved "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.4.tgz"