diff --git a/apps/portal/package.json b/apps/portal/package.json index d57cdf43..9948c01e 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -22,6 +22,7 @@ "@trpc/react": "^9.27.2", "@trpc/server": "^9.27.2", "clsx": "^1.2.1", + "date-fns": "^2.29.3", "next": "12.3.1", "next-auth": "~4.10.3", "react": "18.2.0", diff --git a/apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql b/apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql new file mode 100644 index 00000000..5b9baead --- /dev/null +++ b/apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql @@ -0,0 +1,73 @@ +/* + Warnings: + + - You are about to drop the column `resumesProfileId` on the `ResumesComment` table. All the data in the column will be lost. + - You are about to drop the column `resumesProfileId` on the `ResumesCommentVote` table. All the data in the column will be lost. + - You are about to drop the column `resumesProfileId` on the `ResumesResume` table. All the data in the column will be lost. + - You are about to drop the column `resumesProfileId` on the `ResumesStar` table. All the data in the column will be lost. + - You are about to drop the `ResumesProfile` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[userId,commentId]` on the table `ResumesCommentVote` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[userId,resumeId]` on the table `ResumesStar` will be added. If there are existing duplicate values, this will fail. + - Added the required column `userId` to the `ResumesComment` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `ResumesCommentVote` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `ResumesStar` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "ResumesComment" DROP CONSTRAINT "ResumesComment_resumesProfileId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesCommentVote" DROP CONSTRAINT "ResumesCommentVote_resumesProfileId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesProfile" DROP CONSTRAINT "ResumesProfile_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesResume" DROP CONSTRAINT "ResumesResume_resumesProfileId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesStar" DROP CONSTRAINT "ResumesStar_resumesProfileId_fkey"; + +-- DropIndex +DROP INDEX "ResumesCommentVote_commentId_resumesProfileId_key"; + +-- DropIndex +DROP INDEX "ResumesStar_resumeId_resumesProfileId_key"; + +-- AlterTable +ALTER TABLE "ResumesComment" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesCommentVote" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesResume" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesStar" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- DropTable +DROP TABLE "ResumesProfile"; + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesCommentVote_userId_commentId_key" ON "ResumesCommentVote"("userId", "commentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesStar_userId_resumeId_key" ON "ResumesStar"("userId", "resumeId"); + +-- AddForeignKey +ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesStar" ADD CONSTRAINT "ResumesStar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesCommentVote" ADD CONSTRAINT "ResumesCommentVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index 086eb2bc..7ece9c5d 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -37,15 +37,18 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - accounts Account[] - sessions Session[] - todos Todo[] - resumesProfile ResumesProfile? + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + todos Todo[] + resumesResumes ResumesResume[] + resumesStars ResumesStar[] + resumesComments ResumesComment[] + resumesCommentVotes ResumesCommentVote[] } model VerificationToken { @@ -85,56 +88,45 @@ model Company { // Add Resumes project models here, prefix all models with "Resumes", // use camelCase for field names, and try to name them consistently // across all models in this file. - -model ResumesProfile { - id String @id @default(cuid()) - userId String @unique - resumesResumes ResumesResume[] - resumesStars ResumesStar[] - resumesComments ResumesComment[] - resumesCommentVotes ResumesCommentVote[] - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} - model ResumesResume { - id String @id @default(cuid()) - resumesProfileId String - title String @db.Text + id String @id @default(cuid()) + userId String + title String @db.Text // TODO: Update role, experience, location to use Enums - role String @db.Text - experience String @db.Text - location String @db.Text - url String - additionalInfo String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) - stars ResumesStar[] - comments ResumesComment[] + role String @db.Text + experience String @db.Text + location String @db.Text + url String + additionalInfo String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + stars ResumesStar[] + comments ResumesComment[] } model ResumesStar { - id String @id @default(cuid()) - resumesProfileId String - resumeId String - createdAt DateTime @default(now()) - resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) - resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + resumeId String + createdAt DateTime @default(now()) + resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([resumeId, resumesProfileId]) + @@unique([userId, resumeId]) } model ResumesComment { - id String @id @default(cuid()) - resumesProfileId String - resumeId String - description String @db.Text - section ResumesSection - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) - resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) - votes ResumesCommentVote[] + id String @id @default(cuid()) + userId String + resumeId String + description String @db.Text + section ResumesSection + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + votes ResumesCommentVote[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum ResumesSection { @@ -146,16 +138,16 @@ enum ResumesSection { } model ResumesCommentVote { - id String @id @default(cuid()) - resumesProfileId String - commentId String - value Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) - comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + commentId String + value Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([commentId, resumesProfileId]) + @@unique([userId, commentId]) } // End of Resumes project models. diff --git a/apps/portal/src/components/resumes/ResumePdf.tsx b/apps/portal/src/components/resumes/ResumePdf.tsx index 12debea4..82e26395 100644 --- a/apps/portal/src/components/resumes/ResumePdf.tsx +++ b/apps/portal/src/components/resumes/ResumePdf.tsx @@ -6,7 +6,11 @@ import { Button, Spinner } from '@tih/ui'; pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`; -export default function ResumePdf() { +type Props = Readonly<{ + url: string; +}>; + +export default function ResumePdf({ url }: Props) { const [numPages, setNumPages] = useState(0); const [pageNumber] = useState(1); @@ -18,7 +22,7 @@ export default function ResumePdf() {
} onLoadSuccess={onPdfLoadSuccess}> diff --git a/apps/portal/src/pages/resumes/[resumeId].tsx b/apps/portal/src/pages/resumes/[resumeId].tsx new file mode 100644 index 00000000..5a13a1c3 --- /dev/null +++ b/apps/portal/src/pages/resumes/[resumeId].tsx @@ -0,0 +1,148 @@ +import clsx from 'clsx'; +import formatDistanceToNow from 'date-fns/formatDistanceToNow'; +import Error from 'next/error'; +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; +import { useEffect } from 'react'; +import { + AcademicCapIcon, + BriefcaseIcon, + CalendarIcon, + InformationCircleIcon, + MapPinIcon, + StarIcon, +} from '@heroicons/react/20/solid'; +import { Spinner } from '@tih/ui'; + +import CommentsSection from '~/components/resumes/comments/CommentsSection'; +import ResumePdf from '~/components/resumes/ResumePdf'; + +import { trpc } from '~/utils/trpc'; + +export default function ResumeReviewPage() { + const ErrorPage = ( + + ); + const { data: session } = useSession(); + const router = useRouter(); + const { resumeId } = router.query; + const utils = trpc.useContext(); + // Safe to assert resumeId type as string because query is only sent if so + const detailsQuery = trpc.useQuery( + ['resumes.details.find', { resumeId: resumeId as string }], + { + enabled: typeof resumeId === 'string' && session?.user?.id !== undefined, + }, + ); + const starMutation = trpc.useMutation('resumes.details.update_star', { + onSuccess() { + utils.invalidateQueries(); + }, + }); + + useEffect(() => { + if (detailsQuery.data?.stars.length) { + document.getElementById('star-button')?.focus(); + } else { + document.getElementById('star-button')?.blur(); + } + }, [detailsQuery.data?.stars]); + + const onStarButtonClick = () => { + // Star button only rendered if resume exists + // Star button only clickable if user exists + starMutation.mutate({ + resumeId: resumeId as string, + }); + }; + + return ( + <> + {detailsQuery.isError && ErrorPage} + {detailsQuery.isLoading && } + {detailsQuery.isFetched && detailsQuery.data && ( +
+
+

+ {detailsQuery.data.title} +

+ +
+
+
+
+
+
+
+
+
+
+
+ {detailsQuery.data.additionalInfo && ( +
+
+ )} +
+
+ +
+
+ {/* TODO: Update resumeId */} + +
+
+
+ )} + + ); +} diff --git a/apps/portal/src/pages/resumes/review.tsx b/apps/portal/src/pages/resumes/review.tsx deleted file mode 100644 index f75ae519..00000000 --- a/apps/portal/src/pages/resumes/review.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - AcademicCapIcon, - BriefcaseIcon, - CalendarIcon, - InformationCircleIcon, - MapPinIcon, - StarIcon, -} from '@heroicons/react/20/solid'; - -import CommentsSection from '~/components/resumes/comments/CommentsSection'; -import ResumePdf from '~/components/resumes/ResumePdf'; - -export default function ResumeReviewPage() { - return ( -
-
-

- Please help moi, applying for medtech startups in Singapore -

- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- {/* TODO: Update resumeId */} - -
-
-
- ); -} diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 9c2b451b..16530ff6 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -2,6 +2,7 @@ import superjson from 'superjson'; import { createRouter } from './context'; import { protectedExampleRouter } from './protected-example-router'; +import { resumesDetailsRouter } from './resumes-details-router'; import { resumesResumeUserRouter } from './resumes-resume-user-router'; import { resumeReviewsRouter } from './resumes-reviews-router'; import { resumesReviewsUserRouter } from './resumes-reviews-user-router'; @@ -16,6 +17,7 @@ export const appRouter = createRouter() .merge('auth.', protectedExampleRouter) .merge('todos.', todosRouter) .merge('todos.user.', todosUserRouter) + .merge('resumes.details.', resumesDetailsRouter) .merge('resumes.resume.user.', resumesResumeUserRouter) .merge('resumes.reviews.', resumeReviewsRouter) .merge('resumes.reviews.user.', resumesReviewsUserRouter); diff --git a/apps/portal/src/server/router/resumes-details-router.ts b/apps/portal/src/server/router/resumes-details-router.ts new file mode 100644 index 00000000..1255ac2e --- /dev/null +++ b/apps/portal/src/server/router/resumes-details-router.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +import { createRouter } from './context'; + +export const resumesDetailsRouter = createRouter() + .query('find', { + input: z.object({ + resumeId: z.string(), + }), + async resolve({ ctx, input }) { + const { resumeId } = input; + const userId = ctx.session?.user?.id; + + // Use the resumeId to query all related information of a single resume + // from Resumesresume: + return await ctx.prisma.resumesResume.findUnique({ + include: { + _count: { + select: { + stars: true, + }, + }, + stars: { + where: { + userId, + }, + }, + user: { + select: { + name: true, + }, + }, + }, + where: { + id: resumeId, + }, + }); + }, + }) + .mutation('update_star', { + 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, + }, + where: { + userId_resumeId: { + resumeId, + userId, + }, + }, + }); + + if (resumesStar === null) { + return await ctx.prisma.resumesStar.create({ + data: { + resumeId, + userId, + }, + }); + } + return await ctx.prisma.resumesStar.delete({ + where: { + userId_resumeId: { + resumeId, + userId, + }, + }, + }); + }, + }); diff --git a/apps/portal/src/server/router/resumes-resume-user-router.ts b/apps/portal/src/server/router/resumes-resume-user-router.ts index e4962d0b..9f014795 100644 --- a/apps/portal/src/server/router/resumes-resume-user-router.ts +++ b/apps/portal/src/server/router/resumes-resume-user-router.ts @@ -15,23 +15,12 @@ export const resumesResumeUserRouter = createProtectedRouter().mutation( }), async resolve({ ctx, input }) { const userId = ctx.session?.user.id; - const resumeProfile = await ctx.prisma.resumesProfile.upsert({ - create: { - userId, - }, - update: {}, - where: { - userId, - }, - }); - // TODO: Store file in file storage and retrieve URL - return await ctx.prisma.resumesResume.create({ data: { ...input, - resumesProfileId: resumeProfile.id, url: '', + userId, }, }); }, diff --git a/apps/portal/src/server/router/resumes-reviews-router.ts b/apps/portal/src/server/router/resumes-reviews-router.ts index 2f983c92..8e681326 100644 --- a/apps/portal/src/server/router/resumes-reviews-router.ts +++ b/apps/portal/src/server/router/resumes-reviews-router.ts @@ -7,18 +7,9 @@ export const resumeReviewsRouter = createRouter().query('list', { resumeId: z.string(), }), async resolve({ ctx, input }) { + const userId = ctx.session?.user?.id; const { resumeId } = input; - const { resumesProfileId } = - await ctx.prisma.resumesResume.findUniqueOrThrow({ - select: { - resumesProfileId: true, - }, - where: { - id: resumeId, - }, - }); - // For this resume, we retrieve every comment's information, along with: // The user's name and image to render // Number of votes, and whether the user (if-any) has voted @@ -29,20 +20,16 @@ export const resumeReviewsRouter = createRouter().query('list', { votes: true, }, }, - resumesProfile: { - include: { - user: { - select: { - image: true, - name: true, - }, - }, + user: { + select: { + image: true, + name: true, }, }, votes: { take: 1, where: { - resumesProfileId, + userId, }, }, }, diff --git a/apps/portal/src/server/router/resumes-reviews-user-router.ts b/apps/portal/src/server/router/resumes-reviews-user-router.ts index b13e2a00..5730887f 100644 --- a/apps/portal/src/server/router/resumes-reviews-user-router.ts +++ b/apps/portal/src/server/router/resumes-reviews-user-router.ts @@ -6,8 +6,8 @@ import { createProtectedRouter } from './context'; type IResumeCommentInput = Readonly<{ description: string; resumeId: string; - resumesProfileId: string; section: ResumesSection; + userId: string; }>; export const resumesReviewsUserRouter = createProtectedRouter().mutation( @@ -22,19 +22,10 @@ export const resumesReviewsUserRouter = createProtectedRouter().mutation( skills: z.string(), }), async resolve({ ctx, input }) { + const userId = ctx.session?.user?.id; const { resumeId, education, experience, general, projects, skills } = input; - const { resumesProfileId } = - await ctx.prisma.resumesResume.findUniqueOrThrow({ - select: { - resumesProfileId: true, - }, - where: { - id: resumeId, - }, - }); - // For each section, convert them into ResumesComment model if provided const comments: Array = [ { description: education, section: ResumesSection.EDUCATION }, @@ -50,8 +41,8 @@ export const resumesReviewsUserRouter = createProtectedRouter().mutation( return { description, resumeId, - resumesProfileId, section, + userId, }; }); diff --git a/yarn.lock b/yarn.lock index 8a87bd65..bd4ae44d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6049,7 +6049,7 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== -date-fns@^2.29.1: +date-fns@^2.29.1, date-fns@^2.29.3: version "2.29.3" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==