From 19a2b0f3fd95284cd819aba02eb8b2bf34ca1b90 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Mon, 2 Mar 2026 15:40:27 -0700 Subject: [PATCH 01/12] feat(jobs): add notes to job applications --- package-lock.json | 1 + package.json | 1 + .../migration.sql | 17 +++ prisma/schema.prisma | 16 +++ src/actions/job.actions.ts | 1 + src/actions/note.actions.ts | 121 ++++++++++++++++ src/components/myjobs/AddJob.tsx | 4 + src/components/myjobs/JobDetails.tsx | 2 + src/components/myjobs/JobsContainer.tsx | 15 ++ src/components/myjobs/MyJobsTable.tsx | 24 +++- src/components/myjobs/NoteCard.tsx | 46 ++++++ src/components/myjobs/NoteDialog.tsx | 125 ++++++++++++++++ .../myjobs/NotesCollapsibleSection.tsx | 136 ++++++++++++++++++ src/components/myjobs/NotesSection.tsx | 136 ++++++++++++++++++ src/components/ui/collapsible.tsx | 11 ++ src/models/job.model.ts | 1 + src/models/note.model.ts | 12 ++ src/models/note.schema.ts | 9 ++ 18 files changed, 675 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260302223041_add_note_model/migration.sql create mode 100644 src/actions/note.actions.ts create mode 100644 src/components/myjobs/NoteCard.tsx create mode 100644 src/components/myjobs/NoteDialog.tsx create mode 100644 src/components/myjobs/NotesCollapsibleSection.tsx create mode 100644 src/components/myjobs/NotesSection.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/models/note.model.ts create mode 100644 src/models/note.schema.ts diff --git a/package-lock.json b/package-lock.json index f34607f..04d88de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.3", "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-dropdown-menu": "^2.1.3", "@radix-ui/react-label": "^2.1.1", diff --git a/package.json b/package.json index 36271d9..2f92e42 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.3", "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-dropdown-menu": "^2.1.3", "@radix-ui/react-label": "^2.1.1", diff --git a/prisma/migrations/20260302223041_add_note_model/migration.sql b/prisma/migrations/20260302223041_add_note_model/migration.sql new file mode 100644 index 0000000..0c9157c --- /dev/null +++ b/prisma/migrations/20260302223041_add_note_model/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "Note" ( + "id" TEXT NOT NULL PRIMARY KEY, + "jobId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Note_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "Note_jobId_idx" ON "Note"("jobId"); + +-- CreateIndex +CREATE INDEX "Note_userId_idx" ON "Note"("userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d7436a1..b968b38 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { Automation Automation[] Settings UserSettings? ApiKey ApiKey[] + Note Note[] } model ApiKey { @@ -282,6 +283,7 @@ model Job { Interview Interview[] Resume Resume? @relation(fields: [resumeId], references: [id]) resumeId String? + Notes Note[] // Automation discovery fields automationId String? @@ -406,3 +408,17 @@ model AutomationRun { @@index([automationId]) @@index([startedAt]) } + +model Note { + id String @id @default(uuid()) + jobId String + job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id]) + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([jobId]) + @@index([userId]) +} diff --git a/src/actions/job.actions.ts b/src/actions/job.actions.ts index cff295a..28a740e 100644 --- a/src/actions/job.actions.ts +++ b/src/actions/job.actions.ts @@ -94,6 +94,7 @@ export const getJobsList = async ( description: false, Resume: true, matchScore: true, + _count: { select: { Notes: true } }, }, orderBy: { createdAt: "desc", diff --git a/src/actions/note.actions.ts b/src/actions/note.actions.ts new file mode 100644 index 0000000..8b1dcb7 --- /dev/null +++ b/src/actions/note.actions.ts @@ -0,0 +1,121 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { NoteFormSchema } from "@/models/note.schema"; +import { NoteResponse } from "@/models/note.model"; +import { getCurrentUser } from "@/utils/user.utils"; +import { z } from "zod"; + +export const getNotesByJobId = async ( + jobId: string +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) { + throw new Error("Not authenticated"); + } + + const job = await prisma.job.findFirst({ + where: { id: jobId, userId: user.id }, + select: { id: true }, + }); + if (!job) { + throw new Error("Job not found"); + } + + const notes = await prisma.note.findMany({ + where: { jobId, userId: user.id }, + orderBy: { createdAt: "desc" }, + }); + + const data: NoteResponse[] = notes.map((note) => ({ + ...note, + isEdited: note.updatedAt.getTime() - note.createdAt.getTime() > 1000, + })); + + return { success: true, data }; + } catch (error) { + const msg = "Failed to fetch notes."; + return handleError(error, msg); + } +}; + +export const addNote = async ( + data: z.infer +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) { + throw new Error("Not authenticated"); + } + + const validated = NoteFormSchema.parse(data); + + const job = await prisma.job.findFirst({ + where: { id: validated.jobId, userId: user.id }, + select: { id: true }, + }); + if (!job) { + throw new Error("Job not found"); + } + + const note = await prisma.note.create({ + data: { + jobId: validated.jobId, + userId: user.id, + content: validated.content, + }, + }); + + return { success: true, data: note }; + } catch (error) { + const msg = "Failed to add note."; + return handleError(error, msg); + } +}; + +export const updateNote = async ( + data: z.infer +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) { + throw new Error("Not authenticated"); + } + + const validated = NoteFormSchema.parse(data); + if (!validated.id) { + throw new Error("Note ID is required for update"); + } + + const note = await prisma.note.update({ + where: { id: validated.id, userId: user.id }, + data: { content: validated.content }, + }); + + return { success: true, data: note }; + } catch (error) { + const msg = "Failed to update note."; + return handleError(error, msg); + } +}; + +export const deleteNote = async ( + noteId: string +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) { + throw new Error("Not authenticated"); + } + + await prisma.note.delete({ + where: { id: noteId, userId: user.id }, + }); + + return { success: true }; + } catch (error) { + const msg = "Failed to delete note."; + return handleError(error, msg); + } +}; diff --git a/src/components/myjobs/AddJob.tsx b/src/components/myjobs/AddJob.tsx index ef2a503..4ace990 100644 --- a/src/components/myjobs/AddJob.tsx +++ b/src/components/myjobs/AddJob.tsx @@ -43,6 +43,7 @@ import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; import { redirect } from "next/navigation"; import { Combobox } from "../ComboBox"; +import { NotesCollapsibleSection } from "./NotesCollapsibleSection"; import { Resume } from "@/models/profile.model"; import CreateResume from "../profile/CreateResume"; import { getResumeList } from "@/actions/profile.actions"; @@ -492,6 +493,9 @@ export function AddJob({ )} /> + {editJob && ( + + )}
+ {parsedMatchData && (

diff --git a/src/components/myjobs/JobsContainer.tsx b/src/components/myjobs/JobsContainer.tsx index 55875d3..f94a3ff 100644 --- a/src/components/myjobs/JobsContainer.tsx +++ b/src/components/myjobs/JobsContainer.tsx @@ -40,6 +40,7 @@ import Loading from "../Loading"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { AddJob } from "./AddJob"; import MyJobsTable from "./MyJobsTable"; +import { NoteDialog } from "./NoteDialog"; import { format } from "date-fns"; import { RecordsPerPageSelector } from "../RecordsPerPageSelector"; import { RecordsCount } from "../RecordsCount"; @@ -81,6 +82,8 @@ function JobsContainer({ const [recordsPerPage, setRecordsPerPage] = useState( APP_CONSTANTS.RECORDS_PER_PAGE, ); + const [noteDialogOpen, setNoteDialogOpen] = useState(false); + const [noteJobId, setNoteJobId] = useState(""); const hasSearched = useRef(false); const jobsPerPage = recordsPerPage; @@ -171,6 +174,11 @@ function JobsContainer({ setEditJob(null); }; + const onAddNote = (jobId: string) => { + setNoteJobId(jobId); + setNoteDialogOpen(true); + }; + useEffect(() => { (async () => await loadJobs(1))(); }, [loadJobs]); @@ -299,6 +307,7 @@ function JobsContainer({ deleteJob={onDeleteJob} editJob={onEditJob} onChangeJobStatus={onChangeJobStatus} + onAddNote={onAddNote} />
+ reloadJobs()} + /> ); } diff --git a/src/components/myjobs/MyJobsTable.tsx b/src/components/myjobs/MyJobsTable.tsx index cc14000..399d6d0 100644 --- a/src/components/myjobs/MyJobsTable.tsx +++ b/src/components/myjobs/MyJobsTable.tsx @@ -11,6 +11,7 @@ import { ListCollapse, MoreHorizontal, Pencil, + StickyNote, Tags, Trash, } from "lucide-react"; @@ -43,6 +44,7 @@ type MyJobsTableProps = { deleteJob: (id: string) => void; editJob: (id: string) => void; onChangeJobStatus: (id: string, status: JobStatus) => void; + onAddNote: (jobId: string) => void; }; function MyJobsTable({ @@ -51,6 +53,7 @@ function MyJobsTable({ deleteJob, editJob, onChangeJobStatus, + onAddNote, }: MyJobsTableProps) { const [alertOpen, setAlertOpen] = useState(false); const [jobIdToDelete, setJobIdToDelete] = useState(""); @@ -103,9 +106,17 @@ function MyJobsTable({ - - {job.JobTitle?.label} - +
+ + {job.JobTitle?.label} + + {(job._count?.Notes ?? 0) > 0 && ( + + + {job._count!.Notes} + + )} +
{job.Company?.label} @@ -164,6 +175,13 @@ function MyJobsTable({ Edit Job + onAddNote(job.id)} + > + + Add a Note + diff --git a/src/components/myjobs/NoteCard.tsx b/src/components/myjobs/NoteCard.tsx new file mode 100644 index 0000000..657c4ad --- /dev/null +++ b/src/components/myjobs/NoteCard.tsx @@ -0,0 +1,46 @@ +"use client"; +import { NoteResponse } from "@/models/note.model"; +import { TipTapContentViewer } from "../TipTapContentViewer"; +import { format } from "date-fns"; +import { Button } from "../ui/button"; +import { Pencil, Trash } from "lucide-react"; + +type NoteCardProps = { + note: NoteResponse; + onEdit: (note: NoteResponse) => void; + onDelete: (noteId: string) => void; +}; + +export function NoteCard({ note, onEdit, onDelete }: NoteCardProps) { + return ( +
+
+
+ {format(new Date(note.createdAt), "PPp")} + {note.isEdited && ( + (edited) + )} +
+
+ + +
+
+ +
+ ); +} diff --git a/src/components/myjobs/NoteDialog.tsx b/src/components/myjobs/NoteDialog.tsx new file mode 100644 index 0000000..63979c0 --- /dev/null +++ b/src/components/myjobs/NoteDialog.tsx @@ -0,0 +1,125 @@ +"use client"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "../ui/button"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { NoteFormSchema } from "@/models/note.schema"; +import { NoteResponse } from "@/models/note.model"; +import { addNote, updateNote } from "@/actions/note.actions"; +import { toast } from "../ui/use-toast"; +import { useEffect, useTransition } from "react"; +import { Loader } from "lucide-react"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "../ui/form"; +import TiptapEditor from "../TiptapEditor"; + +type NoteDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + jobId: string; + editNote?: NoteResponse | null; + onSaved: () => void; +}; + +export function NoteDialog({ + open, + onOpenChange, + jobId, + editNote, + onSaved, +}: NoteDialogProps) { + const [isPending, startTransition] = useTransition(); + + const form = useForm>({ + resolver: zodResolver(NoteFormSchema) as any, + defaultValues: { + jobId, + content: "", + }, + }); + + useEffect(() => { + if (editNote) { + form.reset({ id: editNote.id, jobId, content: editNote.content }); + } else { + form.reset({ jobId, content: "" }); + } + }, [editNote, jobId, form, open]); + + function onSubmit(data: z.infer) { + startTransition(async () => { + const result = editNote + ? await updateNote(data) + : await addNote(data); + + if (result.success) { + toast({ + variant: "success", + description: `Note ${editNote ? "updated" : "added"} successfully`, + }); + form.reset({ jobId, content: "" }); + onOpenChange(false); + onSaved(); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: result.message, + }); + } + }); + } + + return ( + + + + {editNote ? "Edit Note" : "Add Note"} + +
+ + ( + + + + + + + )} + /> + + + + + + +
+
+ ); +} diff --git a/src/components/myjobs/NotesCollapsibleSection.tsx b/src/components/myjobs/NotesCollapsibleSection.tsx new file mode 100644 index 0000000..4831051 --- /dev/null +++ b/src/components/myjobs/NotesCollapsibleSection.tsx @@ -0,0 +1,136 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import { NoteResponse } from "@/models/note.model"; +import { getNotesByJobId, deleteNote } from "@/actions/note.actions"; +import { NoteCard } from "./NoteCard"; +import { NoteDialog } from "./NoteDialog"; +import { DeleteAlertDialog } from "../DeleteAlertDialog"; +import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; +import { ChevronDown, PlusCircle, StickyNote } from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; +import { toast } from "../ui/use-toast"; + +type NotesCollapsibleSectionProps = { + jobId: string; +}; + +export function NotesCollapsibleSection({ jobId }: NotesCollapsibleSectionProps) { + const [notes, setNotes] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [editNote, setEditNote] = useState(null); + const [deleteAlertOpen, setDeleteAlertOpen] = useState(false); + const [noteIdToDelete, setNoteIdToDelete] = useState(""); + + const loadNotes = useCallback(async () => { + const result = await getNotesByJobId(jobId); + if (result.success) { + setNotes(result.data); + } + }, [jobId]); + + useEffect(() => { + loadNotes(); + }, [loadNotes]); + + const handleEdit = (note: NoteResponse) => { + setEditNote(note); + setDialogOpen(true); + }; + + const handleDeleteClick = (noteId: string) => { + setNoteIdToDelete(noteId); + setDeleteAlertOpen(true); + }; + + const handleDelete = async () => { + const result = await deleteNote(noteIdToDelete); + if (result.success) { + toast({ + variant: "success", + description: "Note deleted successfully", + }); + loadNotes(); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: result.message, + }); + } + }; + + const handleAddNote = () => { + setEditNote(null); + setDialogOpen(true); + }; + + const handleSaved = () => { + setEditNote(null); + loadNotes(); + }; + + return ( + <> + +
+ + + Notes + {notes.length > 0 && ( + + {notes.length} + + )} + + + +
+ + {notes.length === 0 ? ( +

No notes yet.

+ ) : ( + notes.map((note) => ( + + )) + )} +
+
+ + + + + ); +} diff --git a/src/components/myjobs/NotesSection.tsx b/src/components/myjobs/NotesSection.tsx new file mode 100644 index 0000000..47e5bbc --- /dev/null +++ b/src/components/myjobs/NotesSection.tsx @@ -0,0 +1,136 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import { NoteResponse } from "@/models/note.model"; +import { getNotesByJobId, deleteNote } from "@/actions/note.actions"; +import { NoteCard } from "./NoteCard"; +import { NoteDialog } from "./NoteDialog"; +import { DeleteAlertDialog } from "../DeleteAlertDialog"; +import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; +import { ChevronDown, PlusCircle, StickyNote } from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; +import { toast } from "../ui/use-toast"; + +type NotesSectionProps = { + jobId: string; +}; + +export function NotesSection({ jobId }: NotesSectionProps) { + const [notes, setNotes] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [editNote, setEditNote] = useState(null); + const [deleteAlertOpen, setDeleteAlertOpen] = useState(false); + const [noteIdToDelete, setNoteIdToDelete] = useState(""); + + const loadNotes = useCallback(async () => { + const result = await getNotesByJobId(jobId); + if (result.success) { + setNotes(result.data); + if (result.data.length > 0) setIsOpen(true); + } + }, [jobId]); + + useEffect(() => { + loadNotes(); + }, [loadNotes]); + + const handleEdit = (note: NoteResponse) => { + setEditNote(note); + setDialogOpen(true); + }; + + const handleDeleteClick = (noteId: string) => { + setNoteIdToDelete(noteId); + setDeleteAlertOpen(true); + }; + + const handleDelete = async () => { + const result = await deleteNote(noteIdToDelete); + if (result.success) { + toast({ + variant: "success", + description: "Note deleted successfully", + }); + loadNotes(); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: result.message, + }); + } + }; + + const handleAddNote = () => { + setEditNote(null); + setDialogOpen(true); + }; + + const handleSaved = () => { + setEditNote(null); + loadNotes(); + }; + + return ( + <> + +
+ + + Notes + {notes.length > 0 && ( + + {notes.length} + + )} + + + +
+ + {notes.length === 0 ? ( +

No notes yet.

+ ) : ( + notes.map((note) => ( + + )) + )} +
+
+ + + + + ); +} diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..9fa4894 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/models/job.model.ts b/src/models/job.model.ts index 0b0e58b..3e0ee4a 100644 --- a/src/models/job.model.ts +++ b/src/models/job.model.ts @@ -37,6 +37,7 @@ export interface JobResponse { Resume?: Resume; matchScore?: number | null; matchData?: string | null; + _count?: { Notes?: number }; } export interface JobTitle { diff --git a/src/models/note.model.ts b/src/models/note.model.ts new file mode 100644 index 0000000..0f1b27c --- /dev/null +++ b/src/models/note.model.ts @@ -0,0 +1,12 @@ +export interface Note { + id: string; + jobId: string; + userId: string; + content: string; + createdAt: Date; + updatedAt: Date; +} + +export interface NoteResponse extends Note { + isEdited: boolean; +} diff --git a/src/models/note.schema.ts b/src/models/note.schema.ts new file mode 100644 index 0000000..c65582a --- /dev/null +++ b/src/models/note.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const NoteFormSchema = z.object({ + id: z.string().optional(), + jobId: z.string({ error: "Job ID is required." }), + content: z + .string({ error: "Content is required." }) + .min(1, { message: "Content cannot be empty." }), +}); From 056c6b3d586d25316cdf1ab8b3d92a9f873b7c65 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Mon, 2 Mar 2026 15:43:25 -0700 Subject: [PATCH 02/12] chore: write unit tests for add note --- __tests__/note.actions.spec.ts | 290 +++++++++++++++++++++++++++++++++ __tests__/note.schema.spec.ts | 58 +++++++ 2 files changed, 348 insertions(+) create mode 100644 __tests__/note.actions.spec.ts create mode 100644 __tests__/note.schema.spec.ts diff --git a/__tests__/note.actions.spec.ts b/__tests__/note.actions.spec.ts new file mode 100644 index 0000000..41724c2 --- /dev/null +++ b/__tests__/note.actions.spec.ts @@ -0,0 +1,290 @@ +import { + getNotesByJobId, + addNote, + updateNote, + deleteNote, +} from "@/actions/note.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +jest.mock("@prisma/client", () => { + const mPrismaClient = { + note: { + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + job: { + findFirst: jest.fn(), + }, + }; + return { PrismaClient: jest.fn(() => mPrismaClient) }; +}); + +jest.mock("@/utils/user.utils", () => ({ + getCurrentUser: jest.fn(), +})); + +describe("noteActions", () => { + const mockUser = { id: "user-id" }; + const now = new Date(); + const mockNote = { + id: "note-id", + jobId: "job-id", + userId: mockUser.id, + content: "

Interview went well

", + createdAt: now, + updatedAt: now, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getNotesByJobId", () => { + it("should return notes ordered newest-first", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.findFirst as jest.Mock).mockResolvedValue({ id: "job-id" }); + (prisma.note.findMany as jest.Mock).mockResolvedValue([mockNote]); + + const result = await getNotesByJobId("job-id"); + + expect(result).toEqual({ + success: true, + data: [{ ...mockNote, isEdited: false }], + }); + expect(prisma.note.findMany).toHaveBeenCalledWith({ + where: { jobId: "job-id", userId: mockUser.id }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("should mark notes as edited when updatedAt differs from createdAt by more than 1 second", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.findFirst as jest.Mock).mockResolvedValue({ id: "job-id" }); + + const createdAt = new Date("2026-01-01T10:00:00Z"); + const updatedAt = new Date("2026-01-01T10:05:00Z"); + const editedNote = { ...mockNote, createdAt, updatedAt }; + (prisma.note.findMany as jest.Mock).mockResolvedValue([editedNote]); + + const result = await getNotesByJobId("job-id"); + + expect(result.data[0].isEdited).toBe(true); + }); + + it("should not mark notes as edited when updatedAt is within 1 second of createdAt", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.findFirst as jest.Mock).mockResolvedValue({ id: "job-id" }); + + const createdAt = new Date("2026-01-01T10:00:00.000Z"); + const updatedAt = new Date("2026-01-01T10:00:00.500Z"); + const freshNote = { ...mockNote, createdAt, updatedAt }; + (prisma.note.findMany as jest.Mock).mockResolvedValue([freshNote]); + + const result = await getNotesByJobId("job-id"); + + expect(result.data[0].isEdited).toBe(false); + }); + + it("should return empty array when no notes exist", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.findFirst as jest.Mock).mockResolvedValue({ id: "job-id" }); + (prisma.note.findMany as jest.Mock).mockResolvedValue([]); + + const result = await getNotesByJobId("job-id"); + + expect(result).toEqual({ success: true, data: [] }); + }); + + it("should return error when job is not found", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await getNotesByJobId("non-existent-job"); + + expect(result).toEqual({ success: false, message: "Job not found" }); + }); + + it("should return error when user is not authenticated", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await getNotesByJobId("job-id"); + + expect(result).toEqual({ success: false, message: "Not authenticated" }); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.findFirst as jest.Mock).mockRejectedValue( + new Error("Database error") + ); + + const result = await getNotesByJobId("job-id"); + + expect(result).toEqual({ success: false, message: "Database error" }); + }); + }); + + describe("addNote", () => { + const noteData = { jobId: "job-id", content: "

New note

" }; + + it("should create a note successfully", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.findFirst as jest.Mock).mockResolvedValue({ id: "job-id" }); + (prisma.note.create as jest.Mock).mockResolvedValue(mockNote); + + const result = await addNote(noteData); + + expect(result).toEqual({ success: true, data: mockNote }); + expect(prisma.note.create).toHaveBeenCalledWith({ + data: { + jobId: "job-id", + userId: mockUser.id, + content: "

New note

", + }, + }); + }); + + it("should verify job ownership before creating", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await addNote(noteData); + + expect(result).toEqual({ success: false, message: "Job not found" }); + expect(prisma.note.create).not.toHaveBeenCalled(); + }); + + it("should return error when user is not authenticated", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await addNote(noteData); + + expect(result).toEqual({ success: false, message: "Not authenticated" }); + }); + + it("should reject empty content via validation", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + + const result = await addNote({ jobId: "job-id", content: "" }); + + expect(result.success).toBe(false); + expect(result.message).toBeTruthy(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.findFirst as jest.Mock).mockResolvedValue({ id: "job-id" }); + (prisma.note.create as jest.Mock).mockRejectedValue( + new Error("Database error") + ); + + const result = await addNote(noteData); + + expect(result).toEqual({ success: false, message: "Database error" }); + }); + }); + + describe("updateNote", () => { + const updateData = { + id: "note-id", + jobId: "job-id", + content: "

Updated content

", + }; + + it("should update a note successfully", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + const updatedNote = { ...mockNote, content: updateData.content }; + (prisma.note.update as jest.Mock).mockResolvedValue(updatedNote); + + const result = await updateNote(updateData); + + expect(result).toEqual({ success: true, data: updatedNote }); + expect(prisma.note.update).toHaveBeenCalledWith({ + where: { id: "note-id", userId: mockUser.id }, + data: { content: "

Updated content

" }, + }); + }); + + it("should return error when note ID is missing", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + + const result = await updateNote({ jobId: "job-id", content: "test" }); + + expect(result).toEqual({ + success: false, + message: "Note ID is required for update", + }); + }); + + it("should return error when user is not authenticated", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await updateNote(updateData); + + expect(result).toEqual({ success: false, message: "Not authenticated" }); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.note.update as jest.Mock).mockRejectedValue( + new Error("Database error") + ); + + const result = await updateNote(updateData); + + expect(result).toEqual({ success: false, message: "Database error" }); + }); + }); + + describe("deleteNote", () => { + it("should delete a note successfully", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.note.delete as jest.Mock).mockResolvedValue(mockNote); + + const result = await deleteNote("note-id"); + + expect(result).toEqual({ success: true }); + expect(prisma.note.delete).toHaveBeenCalledWith({ + where: { id: "note-id", userId: mockUser.id }, + }); + }); + + it("should return error when user is not authenticated", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await deleteNote("note-id"); + + expect(result).toEqual({ success: false, message: "Not authenticated" }); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.note.delete as jest.Mock).mockRejectedValue( + new Error("Database error") + ); + + const result = await deleteNote("note-id"); + + expect(result).toEqual({ success: false, message: "Database error" }); + }); + + it("should handle deleting non-existent note", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.note.delete as jest.Mock).mockRejectedValue( + new Error("Record to delete does not exist.") + ); + + const result = await deleteNote("non-existent-id"); + + expect(result).toEqual({ + success: false, + message: "Record to delete does not exist.", + }); + }); + }); +}); diff --git a/__tests__/note.schema.spec.ts b/__tests__/note.schema.spec.ts new file mode 100644 index 0000000..1519b8a --- /dev/null +++ b/__tests__/note.schema.spec.ts @@ -0,0 +1,58 @@ +import { NoteFormSchema } from "@/models/note.schema"; + +describe("NoteFormSchema", () => { + describe("valid data", () => { + it("should accept valid note data", () => { + const data = { jobId: "job-123", content: "Some note content" }; + const result = NoteFormSchema.parse(data); + expect(result.jobId).toBe("job-123"); + expect(result.content).toBe("Some note content"); + }); + + it("should accept data with optional id for editing", () => { + const data = { + id: "note-123", + jobId: "job-123", + content: "Updated content", + }; + const result = NoteFormSchema.parse(data); + expect(result.id).toBe("note-123"); + }); + + it("should accept HTML content from rich text editor", () => { + const data = { + jobId: "job-123", + content: "

Interview prep: focus on system design

", + }; + const result = NoteFormSchema.parse(data); + expect(result.content).toContain(""); + }); + + it("should accept minimal content (1 character)", () => { + const data = { jobId: "job-123", content: "x" }; + const result = NoteFormSchema.parse(data); + expect(result.content).toBe("x"); + }); + }); + + describe("invalid data", () => { + it("should reject empty content", () => { + const data = { jobId: "job-123", content: "" }; + expect(() => NoteFormSchema.parse(data)).toThrow(); + }); + + it("should reject missing jobId", () => { + const data = { content: "Some content" }; + expect(() => NoteFormSchema.parse(data)).toThrow(); + }); + + it("should reject missing content", () => { + const data = { jobId: "job-123" }; + expect(() => NoteFormSchema.parse(data)).toThrow(); + }); + + it("should reject empty object", () => { + expect(() => NoteFormSchema.parse({})).toThrow(); + }); + }); +}); From 1d53dc7c010db8aacdd0cbcc36a1a0a159445633 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Mon, 2 Mar 2026 17:43:48 -0700 Subject: [PATCH 03/12] fix: edit note issue --- .../myjobs/NotesCollapsibleSection.tsx | 281 ++++++++++++------ src/components/myjobs/NotesSection.tsx | 2 +- 2 files changed, 196 insertions(+), 87 deletions(-) diff --git a/src/components/myjobs/NotesCollapsibleSection.tsx b/src/components/myjobs/NotesCollapsibleSection.tsx index 4831051..3f938c4 100644 --- a/src/components/myjobs/NotesCollapsibleSection.tsx +++ b/src/components/myjobs/NotesCollapsibleSection.tsx @@ -1,31 +1,38 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useTransition } from "react"; import { NoteResponse } from "@/models/note.model"; -import { getNotesByJobId, deleteNote } from "@/actions/note.actions"; +import { + getNotesByJobId, + deleteNote, + addNote, + updateNote, +} from "@/actions/note.actions"; import { NoteCard } from "./NoteCard"; -import { NoteDialog } from "./NoteDialog"; -import { DeleteAlertDialog } from "../DeleteAlertDialog"; import { Button } from "../ui/button"; import { Badge } from "../ui/badge"; -import { ChevronDown, PlusCircle, StickyNote } from "lucide-react"; +import { ChevronDown, Loader, PlusCircle, StickyNote } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "../ui/collapsible"; import { toast } from "../ui/use-toast"; +import TiptapEditor from "../TiptapEditor"; type NotesCollapsibleSectionProps = { jobId: string; }; -export function NotesCollapsibleSection({ jobId }: NotesCollapsibleSectionProps) { +export function NotesCollapsibleSection({ + jobId, +}: NotesCollapsibleSectionProps) { const [notes, setNotes] = useState([]); const [isOpen, setIsOpen] = useState(false); - const [dialogOpen, setDialogOpen] = useState(false); - const [editNote, setEditNote] = useState(null); - const [deleteAlertOpen, setDeleteAlertOpen] = useState(false); - const [noteIdToDelete, setNoteIdToDelete] = useState(""); + const [editingNote, setEditingNote] = useState(null); + const [isAdding, setIsAdding] = useState(false); + const [editorContent, setEditorContent] = useState(""); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [isPending, startTransition] = useTransition(); const loadNotes = useCallback(async () => { const result = await getNotesByJobId(jobId); @@ -38,99 +45,201 @@ export function NotesCollapsibleSection({ jobId }: NotesCollapsibleSectionProps) loadNotes(); }, [loadNotes]); + const handleAddNote = () => { + setEditingNote(null); + setEditorContent(""); + setIsAdding(true); + setIsOpen(true); + }; + const handleEdit = (note: NoteResponse) => { - setEditNote(note); - setDialogOpen(true); + setIsAdding(false); + setEditingNote(note); + setEditorContent(note.content); }; - const handleDeleteClick = (noteId: string) => { - setNoteIdToDelete(noteId); - setDeleteAlertOpen(true); + const handleCancel = () => { + setIsAdding(false); + setEditingNote(null); + setEditorContent(""); }; - const handleDelete = async () => { - const result = await deleteNote(noteIdToDelete); - if (result.success) { - toast({ - variant: "success", - description: "Note deleted successfully", - }); - loadNotes(); - } else { - toast({ - variant: "destructive", - title: "Error!", - description: result.message, - }); - } + const handleSave = () => { + if (!editorContent.trim()) return; + + startTransition(async () => { + const result = editingNote + ? await updateNote({ + id: editingNote.id, + jobId, + content: editorContent, + }) + : await addNote({ jobId, content: editorContent }); + + if (result.success) { + toast({ + variant: "success", + description: `Note ${editingNote ? "updated" : "added"} successfully`, + }); + handleCancel(); + loadNotes(); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: result.message, + }); + } + }); }; - const handleAddNote = () => { - setEditNote(null); - setDialogOpen(true); + const handleDeleteClick = (noteId: string) => { + setDeleteConfirmId(noteId); }; - const handleSaved = () => { - setEditNote(null); - loadNotes(); + const handleDeleteConfirm = () => { + if (!deleteConfirmId) return; + + startTransition(async () => { + const result = await deleteNote(deleteConfirmId); + if (result.success) { + toast({ + variant: "success", + description: "Note deleted successfully", + }); + setDeleteConfirmId(null); + loadNotes(); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: result.message, + }); + } + }); }; + const inlineEditor = ( +
+

+ {editingNote ? "Edit Note" : "Add Note"} +

+ setEditorContent(val), + onBlur: () => {}, + name: "content" as const, + ref: () => {}, + } as any + } + /> +
+ + +
+
+ ); + return ( - <> - -
- - - Notes - {notes.length > 0 && ( - - {notes.length} - - )} - - - -
- - {notes.length === 0 ? ( -

No notes yet.

- ) : ( - notes.map((note) => ( + +
+ + + Notes + {notes.length > 0 && ( + + {notes.length} + + )} + + + +
+ + {isAdding && inlineEditor} + {notes.length === 0 && !isAdding ? ( +

No notes yet.

+ ) : ( + notes.map((note) => + editingNote?.id === note.id ? ( +
{inlineEditor}
+ ) : deleteConfirmId === note.id ? ( +
+

+ Are you sure you want to delete this note? +

+

+ This action cannot be undone. +

+
+ + +
+
+ ) : ( - )) - )} -
-
- - - - + ), + ) + )} +
+
); } diff --git a/src/components/myjobs/NotesSection.tsx b/src/components/myjobs/NotesSection.tsx index 47e5bbc..5d416da 100644 --- a/src/components/myjobs/NotesSection.tsx +++ b/src/components/myjobs/NotesSection.tsx @@ -99,7 +99,7 @@ export function NotesSection({ jobId }: NotesSectionProps) { onClick={handleAddNote} > - Add Note + New Note
From 5f37f34fb62f32a1fd4bb99f442f70e571133f4c Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Mon, 2 Mar 2026 19:08:10 -0700 Subject: [PATCH 04/12] feat(jobs): add skill tags to jobs --- __tests__/AddJob.spec.tsx | 6 +- __tests__/JobsContainer.spec.tsx | 66 +++++-- __tests__/job.actions.spec.ts | 54 +++--- .../migration.sql | 25 +++ prisma/schema.prisma | 61 +++--- src/actions/job.actions.ts | 27 ++- src/actions/tag.actions.ts | 113 +++++++++++ src/app/dashboard/myjobs/page.tsx | 18 +- src/components/admin/AddTag.tsx | 135 +++++++++++++ src/components/admin/AdminTabsContainer.tsx | 7 +- src/components/admin/TagsContainer.tsx | 100 ++++++++++ src/components/admin/TagsTable.tsx | 130 +++++++++++++ src/components/myjobs/AddJob.tsx | 39 +++- src/components/myjobs/JobDetails.tsx | 13 +- src/components/myjobs/JobsContainer.tsx | 14 +- src/components/myjobs/TagInput.tsx | 181 ++++++++++++++++++ src/models/addJobForm.schema.ts | 1 + src/models/job.model.ts | 11 ++ 18 files changed, 917 insertions(+), 84 deletions(-) create mode 100644 prisma/migrations/20260303014226_add_tag_model/migration.sql create mode 100644 src/actions/tag.actions.ts create mode 100644 src/components/admin/AddTag.tsx create mode 100644 src/components/admin/TagsContainer.tsx create mode 100644 src/components/admin/TagsTable.tsx create mode 100644 src/components/myjobs/TagInput.tsx diff --git a/__tests__/AddJob.spec.tsx b/__tests__/AddJob.spec.tsx index c090cb0..21d49b9 100644 --- a/__tests__/AddJob.spec.tsx +++ b/__tests__/AddJob.spec.tsx @@ -70,9 +70,10 @@ describe("AddJob Component", () => { jobTitles={mockJobTitles} locations={mockLocations} jobSources={mockJobSources} + tags={[]} editJob={null} resetEditJob={mockResetEditJob} - /> + />, ); const addJobButton = screen.getByTestId("add-job-btn"); await user.click(addJobButton); @@ -112,7 +113,7 @@ describe("AddJob Component", () => { expect(screen.getByText("Location is required.")).toBeInTheDocument(); expect(screen.getByText("Source is required.")).toBeInTheDocument(); expect( - screen.getByText("Job description is required.") + screen.getByText("Job description is required."), ).toBeInTheDocument(); }); it("should close the dialog when clicked on cancel button", async () => { @@ -224,6 +225,7 @@ describe("AddJob Component", () => { jobDescription: "

New Job Description

", jobUrl: undefined, applied: false, + tags: [], }); }); }); diff --git a/__tests__/JobsContainer.spec.tsx b/__tests__/JobsContainer.spec.tsx index ff332c3..6705d46 100644 --- a/__tests__/JobsContainer.spec.tsx +++ b/__tests__/JobsContainer.spec.tsx @@ -123,7 +123,12 @@ describe("JobsContainer Search Functionality", () => { const mockLocations = [ { id: "1", label: "Remote", value: "remote", createdBy: "user-1" }, - { id: "2", label: "San Francisco", value: "san francisco", createdBy: "user-1" }, + { + id: "2", + label: "San Francisco", + value: "san francisco", + createdBy: "user-1", + }, ]; const mockSources = [ @@ -135,9 +140,25 @@ describe("JobsContainer Search Functionality", () => { { id: "1", userId: "user-1", - JobTitle: { id: "1", label: "Full Stack Developer", value: "full stack developer", createdBy: "user-1" }, - Company: { id: "1", label: "Amazon", value: "amazon", createdBy: "user-1", logoUrl: "" }, - Location: { id: "1", label: "Remote", value: "remote", createdBy: "user-1" }, + JobTitle: { + id: "1", + label: "Full Stack Developer", + value: "full stack developer", + createdBy: "user-1", + }, + Company: { + id: "1", + label: "Amazon", + value: "amazon", + createdBy: "user-1", + logoUrl: "", + }, + Location: { + id: "1", + label: "Remote", + value: "remote", + createdBy: "user-1", + }, Status: { id: "1", label: "Applied", value: "applied" }, JobSource: { id: "1", label: "Indeed", value: "indeed" }, jobType: "FT", @@ -147,9 +168,25 @@ describe("JobsContainer Search Functionality", () => { { id: "2", userId: "user-1", - JobTitle: { id: "2", label: "Frontend Developer", value: "frontend developer", createdBy: "user-1" }, - Company: { id: "2", label: "Google", value: "google", createdBy: "user-1", logoUrl: "" }, - Location: { id: "2", label: "San Francisco", value: "san francisco", createdBy: "user-1" }, + JobTitle: { + id: "2", + label: "Frontend Developer", + value: "frontend developer", + createdBy: "user-1", + }, + Company: { + id: "2", + label: "Google", + value: "google", + createdBy: "user-1", + logoUrl: "", + }, + Location: { + id: "2", + label: "San Francisco", + value: "san francisco", + createdBy: "user-1", + }, Status: { id: "2", label: "Interview", value: "interview" }, JobSource: { id: "2", label: "LinkedIn", value: "linkedin" }, jobType: "FT", @@ -182,7 +219,8 @@ describe("JobsContainer Search Functionality", () => { titles={mockTitles} locations={mockLocations} sources={mockSources} - /> + tags={[]} + />, ); }; @@ -197,7 +235,9 @@ describe("JobsContainer Search Functionality", () => { renderComponent(); await waitFor(() => { - expect(screen.getByPlaceholderText("Search jobs...")).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Search jobs..."), + ).toBeInTheDocument(); }); }); @@ -211,7 +251,9 @@ describe("JobsContainer Search Functionality", () => { renderComponent(); await waitFor(() => { - expect(screen.getByPlaceholderText("Search jobs...")).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Search jobs..."), + ).toBeInTheDocument(); }); const searchInput = screen.getByPlaceholderText("Search jobs..."); @@ -356,7 +398,9 @@ describe("JobsContainer Search Functionality", () => { renderComponent(); await waitFor(() => { - expect(screen.getByPlaceholderText("Search jobs...")).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Search jobs..."), + ).toBeInTheDocument(); }); // Type in search diff --git a/__tests__/job.actions.spec.ts b/__tests__/job.actions.spec.ts index 2e36441..5cb0cfc 100644 --- a/__tests__/job.actions.spec.ts +++ b/__tests__/job.actions.spec.ts @@ -67,6 +67,7 @@ describe("jobActions", () => { applied: true, userId: mockUser.id, resume: "", + tags: [], }; beforeEach(() => { jest.clearAllMocks(); @@ -91,7 +92,7 @@ describe("jobActions", () => { message: "Failed to fetch status list.", }; (prisma.jobStatus.findMany as jest.Mock).mockRejectedValue( - new Error("Failed to fetch status list.") + new Error("Failed to fetch status list."), ); await expect(getStatusList()).resolves.toStrictEqual(mockErrorResponse); @@ -113,7 +114,7 @@ describe("jobActions", () => { it("should returns failure response on error", async () => { (prisma.jobSource.findMany as jest.Mock).mockRejectedValue( - new Error("Failed to fetch job source list.") + new Error("Failed to fetch job source list."), ); const result = await getJobSourceList(); @@ -148,7 +149,7 @@ describe("jobActions", () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); (prisma.job.findMany as jest.Mock).mockRejectedValue( - new Error("Database error") + new Error("Database error"), ); const result = await getJobsList(); @@ -189,7 +190,7 @@ describe("jobActions", () => { { description: { contains: "Amazon" } }, ], }), - }) + }), ); expect(prisma.job.count).toHaveBeenCalledWith( expect.objectContaining({ @@ -201,7 +202,7 @@ describe("jobActions", () => { { description: { contains: "Amazon" } }, ], }), - }) + }), ); }); @@ -212,7 +213,8 @@ describe("jobActions", () => { await getJobsList(1, 10, undefined, "Developer"); - const findManyCall = (prisma.job.findMany as jest.Mock).mock.calls[0][0]; + const findManyCall = (prisma.job.findMany as jest.Mock).mock + .calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ JobTitle: { label: { contains: "Developer" } }, }); @@ -225,7 +227,8 @@ describe("jobActions", () => { await getJobsList(1, 10, undefined, "Google"); - const findManyCall = (prisma.job.findMany as jest.Mock).mock.calls[0][0]; + const findManyCall = (prisma.job.findMany as jest.Mock).mock + .calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ Company: { label: { contains: "Google" } }, }); @@ -238,7 +241,8 @@ describe("jobActions", () => { await getJobsList(1, 10, undefined, "Remote"); - const findManyCall = (prisma.job.findMany as jest.Mock).mock.calls[0][0]; + const findManyCall = (prisma.job.findMany as jest.Mock).mock + .calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ Location: { label: { contains: "Remote" } }, }); @@ -251,7 +255,8 @@ describe("jobActions", () => { await getJobsList(1, 10, undefined, "React"); - const findManyCall = (prisma.job.findMany as jest.Mock).mock.calls[0][0]; + const findManyCall = (prisma.job.findMany as jest.Mock).mock + .calls[0][0]; expect(findManyCall.where.OR).toContainEqual({ description: { contains: "React" }, }); @@ -264,7 +269,8 @@ describe("jobActions", () => { await getJobsList(1, 10, "applied", "Developer"); - const findManyCall = (prisma.job.findMany as jest.Mock).mock.calls[0][0]; + const findManyCall = (prisma.job.findMany as jest.Mock).mock + .calls[0][0]; expect(findManyCall.where).toMatchObject({ userId: mockUser.id, Status: { value: "applied" }, @@ -279,7 +285,8 @@ describe("jobActions", () => { await getJobsList(1, 10, undefined, undefined); - const findManyCall = (prisma.job.findMany as jest.Mock).mock.calls[0][0]; + const findManyCall = (prisma.job.findMany as jest.Mock).mock + .calls[0][0]; expect(findManyCall.where.OR).toBeUndefined(); }); @@ -290,7 +297,8 @@ describe("jobActions", () => { await getJobsList(1, 10, undefined, ""); - const findManyCall = (prisma.job.findMany as jest.Mock).mock.calls[0][0]; + const findManyCall = (prisma.job.findMany as jest.Mock).mock + .calls[0][0]; expect(findManyCall.where.OR).toBeUndefined(); }); @@ -322,7 +330,8 @@ describe("jobActions", () => { await getJobsList(1, 10, "PT", "Developer"); - const findManyCall = (prisma.job.findMany as jest.Mock).mock.calls[0][0]; + const findManyCall = (prisma.job.findMany as jest.Mock).mock + .calls[0][0]; expect(findManyCall.where).toMatchObject({ userId: mockUser.id, jobType: "PT", @@ -370,6 +379,7 @@ describe("jobActions", () => { File: true, }, }, + tags: true, }, }); }); @@ -378,7 +388,7 @@ describe("jobActions", () => { (getCurrentUser as jest.Mock).mockResolvedValue({ id: "user123" }); (prisma.job.findUnique as jest.Mock).mockRejectedValue( - new Error("Unexpected error") + new Error("Unexpected error"), ); await expect(getJobDetails("job123")).resolves.toStrictEqual({ @@ -438,7 +448,7 @@ describe("jobActions", () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); (prisma.location.findFirst as jest.Mock).mockResolvedValue(null); (prisma.location.create as jest.Mock).mockRejectedValue( - new Error("Unexpected error") + new Error("Unexpected error"), ); await expect(createLocation("location-name")).resolves.toStrictEqual({ @@ -510,7 +520,7 @@ describe("jobActions", () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); (prisma.job.create as jest.Mock).mockRejectedValue( - new Error("Unexpected error") + new Error("Unexpected error"), ); await expect(addJob(jobData)).resolves.toStrictEqual({ @@ -541,7 +551,7 @@ describe("jobActions", () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); (prisma.job.update as jest.Mock).mockRejectedValue( - new Error("Unexpected error") + new Error("Unexpected error"), ); await expect(updateJob(jobData)).resolves.toStrictEqual({ @@ -562,7 +572,7 @@ describe("jobActions", () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); await expect( - updateJob({ ...jobData, id: undefined }) + updateJob({ ...jobData, id: undefined }), ).resolves.toStrictEqual({ success: false, message: "Id is not provide or no user privilages", @@ -615,11 +625,11 @@ describe("jobActions", () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); (prisma.job.update as jest.Mock).mockRejectedValue( - new Error("Unexpected error") + new Error("Unexpected error"), ); await expect( - updateJobStatus(jobData.id, jobData.status) + updateJobStatus(jobData.id, jobData.status), ).resolves.toStrictEqual({ success: false, message: "Unexpected error", @@ -629,7 +639,7 @@ describe("jobActions", () => { (getCurrentUser as jest.Mock).mockResolvedValue(null); await expect( - updateJobStatus(jobData.id, jobData.status) + updateJobStatus(jobData.id, jobData.status), ).resolves.toStrictEqual({ success: false, message: "Not authenticated", @@ -664,7 +674,7 @@ describe("jobActions", () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); (prisma.job.delete as jest.Mock).mockRejectedValue( - new Error("Unexpected error") + new Error("Unexpected error"), ); await expect(deleteJobById("job-id")).resolves.toStrictEqual({ diff --git a/prisma/migrations/20260303014226_add_tag_model/migration.sql b/prisma/migrations/20260303014226_add_tag_model/migration.sql new file mode 100644 index 0000000..1d9330f --- /dev/null +++ b/prisma/migrations/20260303014226_add_tag_model/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "Tag" ( + "id" TEXT NOT NULL PRIMARY KEY, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + CONSTRAINT "Tag_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "_JobToTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + CONSTRAINT "_JobToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_JobToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Tag_value_createdBy_key" ON "Tag"("value", "createdBy"); + +-- CreateIndex +CREATE UNIQUE INDEX "_JobToTag_AB_unique" ON "_JobToTag"("A", "B"); + +-- CreateIndex +CREATE INDEX "_JobToTag_B_index" ON "_JobToTag"("B"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b968b38..c5d5393 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,7 @@ model User { Settings UserSettings? ApiKey ApiKey[] Note Note[] + Tag Tag[] } model ApiKey { @@ -284,6 +285,7 @@ model Job { Resume Resume? @relation(fields: [resumeId], references: [id]) resumeId String? Notes Note[] + tags Tag[] // Automation discovery fields automationId String? @@ -359,26 +361,26 @@ model Task { } model Automation { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id]) + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) name String jobBoard String keywords String location String resumeId String - resume Resume @relation(fields: [resumeId], references: [id]) - matchThreshold Int @default(80) + resume Resume @relation(fields: [resumeId], references: [id]) + matchThreshold Int @default(80) - scheduleHour Int - nextRunAt DateTime? - lastRunAt DateTime? + scheduleHour Int + nextRunAt DateTime? + lastRunAt DateTime? - status String @default("active") + status String @default("active") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt runs AutomationRun[] discoveredJobs Job[] @@ -388,22 +390,22 @@ model Automation { } model AutomationRun { - id String @id @default(uuid()) - automationId String - automation Automation @relation(fields: [automationId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + automationId String + automation Automation @relation(fields: [automationId], references: [id], onDelete: Cascade) - jobsSearched Int @default(0) - jobsDeduplicated Int @default(0) - jobsProcessed Int @default(0) - jobsMatched Int @default(0) - jobsSaved Int @default(0) + jobsSearched Int @default(0) + jobsDeduplicated Int @default(0) + jobsProcessed Int @default(0) + jobsMatched Int @default(0) + jobsSaved Int @default(0) - status String @default("running") - errorMessage String? - blockedReason String? + status String @default("running") + errorMessage String? + blockedReason String? - startedAt DateTime @default(now()) - completedAt DateTime? + startedAt DateTime @default(now()) + completedAt DateTime? @@index([automationId]) @@index([startedAt]) @@ -422,3 +424,14 @@ model Note { @@index([jobId]) @@index([userId]) } + +model Tag { + id String @id @default(uuid()) + label String + value String + createdBy String + user User @relation(fields: [createdBy], references: [id]) + jobs Job[] + + @@unique([value, createdBy]) +} diff --git a/src/actions/job.actions.ts b/src/actions/job.actions.ts index 28a740e..1658142 100644 --- a/src/actions/job.actions.ts +++ b/src/actions/job.actions.ts @@ -40,7 +40,7 @@ export const getJobsList = async ( page: number = 1, limit: number = APP_CONSTANTS.RECORDS_PER_PAGE, filter?: string, - search?: string + search?: string, ): Promise => { try { const user = await getCurrentUser(); @@ -162,7 +162,7 @@ export async function* getJobsIterator(filter?: string, pageSize = 200) { } export const getJobDetails = async ( - jobId: string + jobId: string, ): Promise => { try { if (!jobId) { @@ -189,6 +189,7 @@ export const getJobDetails = async ( File: true, }, }, + tags: true, }, }); return { job, success: true }; @@ -199,7 +200,7 @@ export const getJobDetails = async ( }; export const createLocation = async ( - label: string + label: string, ): Promise => { try { const user = await getCurrentUser(); @@ -233,7 +234,7 @@ export const createLocation = async ( }; export const createJobSource = async ( - label: string + label: string, ): Promise => { try { const user = await getCurrentUser(); @@ -267,7 +268,7 @@ export const createJobSource = async ( }; export const addJob = async ( - data: z.infer + data: z.infer, ): Promise => { try { const user = await getCurrentUser(); @@ -290,8 +291,11 @@ export const addJob = async ( jobUrl, applied, resume, + tags, } = data; + const tagIds = tags ?? []; + const job = await prisma.job.create({ data: { jobTitleId: title, @@ -309,6 +313,9 @@ export const addJob = async ( jobUrl, applied, resumeId: resume, + ...(tagIds.length > 0 + ? { tags: { connect: tagIds.map((id) => ({ id })) } } + : {}), }, }); return { job, success: true }; @@ -319,7 +326,7 @@ export const addJob = async ( }; export const updateJob = async ( - data: z.infer + data: z.infer, ): Promise => { try { const user = await getCurrentUser(); @@ -346,8 +353,11 @@ export const updateJob = async ( jobUrl, applied, resume, + tags, } = data; + const tagIds = tags ?? []; + const job = await prisma.job.update({ where: { id, @@ -367,6 +377,7 @@ export const updateJob = async ( jobUrl, applied, resumeId: resume, + tags: { set: tagIds.map((id) => ({ id })) }, }, }); // revalidatePath("/dashboard/myjobs", "page"); @@ -379,7 +390,7 @@ export const updateJob = async ( export const updateJobStatus = async ( jobId: string, - status: JobStatus + status: JobStatus, ): Promise => { try { const user = await getCurrentUser(); @@ -422,7 +433,7 @@ export const updateJobStatus = async ( }; export const deleteJobById = async ( - jobId: string + jobId: string, ): Promise => { try { const user = await getCurrentUser(); diff --git a/src/actions/tag.actions.ts b/src/actions/tag.actions.ts new file mode 100644 index 0000000..49acfd4 --- /dev/null +++ b/src/actions/tag.actions.ts @@ -0,0 +1,113 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { getCurrentUser } from "@/utils/user.utils"; +import { APP_CONSTANTS } from "@/lib/constants"; + +export const getAllTags = async (): Promise => { + try { + const user = await getCurrentUser(); + if (!user) { + throw new Error("Not authenticated"); + } + const list = await prisma.tag.findMany({ + where: { createdBy: user.id }, + orderBy: { label: "asc" }, + }); + return list; + } catch (error) { + const msg = "Failed to fetch tag list. "; + return handleError(error, msg); + } +}; + +export const getTagList = async ( + page: number = 1, + limit: number = APP_CONSTANTS.RECORDS_PER_PAGE, +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) { + throw new Error("Not authenticated"); + } + const skip = (page - 1) * limit; + + const [data, total] = await Promise.all([ + prisma.tag.findMany({ + where: { createdBy: user.id }, + skip, + take: limit, + select: { + id: true, + label: true, + value: true, + _count: { select: { jobs: true } }, + }, + orderBy: { label: "asc" }, + }), + prisma.tag.count({ where: { createdBy: user.id } }), + ]); + + return { data, total }; + } catch (error) { + const msg = "Failed to fetch tag list. "; + return handleError(error, msg); + } +}; + +export const createTag = async (label: string): Promise => { + try { + const user = await getCurrentUser(); + if (!user) { + throw new Error("Not authenticated"); + } + + const trimmed = label.trim(); + if (!trimmed) { + throw new Error("Tag label cannot be empty."); + } + + const value = trimmed.toLowerCase(); + + const tag = await prisma.tag.upsert({ + where: { value_createdBy: { value, createdBy: user.id } }, + update: {}, + create: { label: trimmed, value, createdBy: user.id }, + }); + + return { data: tag, success: true }; + } catch (error) { + const msg = "Failed to create tag. "; + return handleError(error, msg); + } +}; + +export const deleteTagById = async ( + tagId: string, +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) { + throw new Error("Not authenticated"); + } + + const jobs = await prisma.job.count({ + where: { tags: { some: { id: tagId } } }, + }); + + if (jobs > 0) { + throw new Error( + `Skill tag cannot be deleted because it is linked to ${jobs} job(s).`, + ); + } + + const res = await prisma.tag.delete({ + where: { id: tagId, createdBy: user.id }, + }); + + return { res, success: true }; + } catch (error) { + const msg = "Failed to delete tag."; + return handleError(error, msg); + } +}; diff --git a/src/app/dashboard/myjobs/page.tsx b/src/app/dashboard/myjobs/page.tsx index 90bf512..1bbbfc2 100644 --- a/src/app/dashboard/myjobs/page.tsx +++ b/src/app/dashboard/myjobs/page.tsx @@ -5,19 +5,22 @@ import JobsContainer from "@/components/myjobs/JobsContainer"; import { getAllCompanies } from "@/actions/company.actions"; import { getAllJobTitles } from "@/actions/jobtitle.actions"; import { getAllJobLocations } from "@/actions/jobLocation.actions"; +import { getAllTags } from "@/actions/tag.actions"; export const metadata: Metadata = { title: "My Jobs | JobSync", }; async function MyJobs() { - const [statuses, companies, titles, locations, sources] = await Promise.all([ - getStatusList(), - getAllCompanies(), - getAllJobTitles(), - getAllJobLocations(), - getJobSourceList(), - ]); + const [statuses, companies, titles, locations, sources, tags] = + await Promise.all([ + getStatusList(), + getAllCompanies(), + getAllJobTitles(), + getAllJobLocations(), + getJobSourceList(), + getAllTags(), + ]); return (
); diff --git a/src/components/admin/AddTag.tsx b/src/components/admin/AddTag.tsx new file mode 100644 index 0000000..0614df7 --- /dev/null +++ b/src/components/admin/AddTag.tsx @@ -0,0 +1,135 @@ +"use client"; +import { useTransition, useState } from "react"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { Loader, PlusCircle } from "lucide-react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../ui/form"; +import { Input } from "../ui/input"; +import { toast } from "../ui/use-toast"; +import { createTag } from "@/actions/tag.actions"; + +const AddTagFormSchema = z.object({ + label: z + .string({ error: "Skill label is required." }) + .min(1, { message: "Skill label cannot be empty." }) + .max(60, { message: "Skill label must be 60 characters or fewer." }), +}); + +type AddTagProps = { + reloadTags: () => void; +}; + +function AddTag({ reloadTags }: AddTagProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + + const form = useForm>({ + resolver: zodResolver(AddTagFormSchema), + defaultValues: { label: "" }, + }); + + const { reset } = form; + + const openDialog = () => { + reset(); + setDialogOpen(true); + }; + + const onSubmit = (values: z.infer) => { + startTransition(async () => { + const result = await createTag(values.label); + if (result?.success) { + toast({ + variant: "success", + description: "Skill tag has been added successfully.", + }); + setDialogOpen(false); + reset(); + reloadTags(); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: result?.message ?? "Failed to create skill tag.", + }); + } + }); + }; + + return ( + <> + + + + + Add Skill + + Add a new skill tag to use across your job applications. + + +
+ + ( + + Skill Name + + + + + + )} + /> + + + + + + +
+
+ + ); +} + +export default AddTag; diff --git a/src/components/admin/AdminTabsContainer.tsx b/src/components/admin/AdminTabsContainer.tsx index a5801c5..450b2df 100644 --- a/src/components/admin/AdminTabsContainer.tsx +++ b/src/components/admin/AdminTabsContainer.tsx @@ -3,6 +3,7 @@ import CompaniesContainer from "@/components/admin/CompaniesContainer"; import JobLocationsContainer from "@/components/admin/JobLocationsContainer"; import JobSourcesContainer from "@/components/admin/JobSourcesContainer"; import JobTitlesContainer from "@/components/admin/JobTitlesContainer"; +import TagsContainer from "@/components/admin/TagsContainer"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback } from "react"; @@ -19,7 +20,7 @@ function AdminTabsContainer() { return params.toString(); }, - [queryParams] + [queryParams], ); const onTabChange = (tab: string) => { @@ -35,6 +36,7 @@ function AdminTabsContainer() { Job Titles Locations Sources + Skills @@ -48,6 +50,9 @@ function AdminTabsContainer() { + + + ); } diff --git a/src/components/admin/TagsContainer.tsx b/src/components/admin/TagsContainer.tsx new file mode 100644 index 0000000..2b948cf --- /dev/null +++ b/src/components/admin/TagsContainer.tsx @@ -0,0 +1,100 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { Tag } from "@/models/job.model"; +import { getTagList } from "@/actions/tag.actions"; +import { APP_CONSTANTS } from "@/lib/constants"; +import Loading from "../Loading"; +import { Button } from "../ui/button"; +import { RecordsPerPageSelector } from "../RecordsPerPageSelector"; +import { RecordsCount } from "../RecordsCount"; +import TagsTable from "./TagsTable"; +import AddTag from "./AddTag"; + +function TagsContainer() { + const [tags, setTags] = useState([]); + const [totalTags, setTotalTags] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [recordsPerPage, setRecordsPerPage] = useState( + APP_CONSTANTS.RECORDS_PER_PAGE, + ); + + const loadTags = useCallback( + async (page: number) => { + setLoading(true); + try { + const { data, total } = await getTagList(page, recordsPerPage); + if (data) { + setTags((prev) => (page === 1 ? data : [...prev, ...data])); + setTotalTags(total); + setPage(page); + } + } finally { + setLoading(false); + } + }, + [recordsPerPage], + ); + + const reloadTags = useCallback(async () => { + await loadTags(1); + }, [loadTags]); + + useEffect(() => { + (async () => await loadTags(1))(); + }, [loadTags, recordsPerPage]); + + return ( + <> +
+ + + Skills +
+
+ +
+
+
+ + {loading && } + {tags.length > 0 && ( + <> + +
+ + {totalTags > APP_CONSTANTS.RECORDS_PER_PAGE && ( + + )} +
+ + )} + {tags.length < totalTags && ( +
+ +
+ )} +
+
+
+ + ); +} + +export default TagsContainer; diff --git a/src/components/admin/TagsTable.tsx b/src/components/admin/TagsTable.tsx new file mode 100644 index 0000000..34bd295 --- /dev/null +++ b/src/components/admin/TagsTable.tsx @@ -0,0 +1,130 @@ +"use client"; +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { Tag } from "@/models/job.model"; +import { MoreHorizontal, Trash } from "lucide-react"; +import { useState } from "react"; +import { deleteTagById } from "@/actions/tag.actions"; +import { toast } from "../ui/use-toast"; +import { DeleteAlertDialog } from "../DeleteAlertDialog"; +import { AlertDialog } from "@/models/alertDialog.model"; + +type TagsTableProps = { + tags: Tag[]; + reloadTags: () => void; +}; + +function TagsTable({ tags, reloadTags }: TagsTableProps) { + const [alert, setAlert] = useState({ + openState: false, + deleteAction: false, + }); + + const onDeleteTag = (tag: Tag) => { + if ((tag._count?.jobs ?? 0) > 0) { + setAlert({ + openState: true, + title: "Skill is in use!", + description: `This skill is linked to ${tag._count?.jobs} job(s) and cannot be deleted.`, + deleteAction: false, + }); + } else { + setAlert({ + openState: true, + deleteAction: true, + itemId: tag.id, + }); + } + }; + + const deleteTag = async (tagId: string | undefined) => { + if (!tagId) return; + const { success, message } = await deleteTagById(tagId); + if (success) { + toast({ + variant: "success", + description: "Skill tag has been deleted successfully", + }); + reloadTags(); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: message, + }); + } + }; + + return ( + <> + + + + Skill Label + Value + # Jobs + Actions + + + + {tags.map((tag: Tag) => ( + + {tag.label} + + {tag.value} + + + {tag._count?.jobs ?? 0} + + + + + + + + Actions + onDeleteTag(tag)} + > + + Delete + + + + + + ))} + +
+ setAlert({ openState: false, deleteAction: false })} + onDelete={() => deleteTag(alert.itemId)} + alertTitle={alert.title} + alertDescription={alert.description} + deleteAction={alert.deleteAction} + /> + + ); +} + +export default TagsTable; diff --git a/src/components/myjobs/AddJob.tsx b/src/components/myjobs/AddJob.tsx index 4ace990..097359c 100644 --- a/src/components/myjobs/AddJob.tsx +++ b/src/components/myjobs/AddJob.tsx @@ -22,6 +22,7 @@ import { JobSource, JobStatus, JobTitle, + Tag, } from "@/models/job.model"; import { addDays } from "date-fns"; import { z } from "zod"; @@ -47,6 +48,7 @@ import { NotesCollapsibleSection } from "./NotesCollapsibleSection"; import { Resume } from "@/models/profile.model"; import CreateResume from "../profile/CreateResume"; import { getResumeList } from "@/actions/profile.actions"; +import { TagInput } from "./TagInput"; type AddJobProps = { jobStatuses: JobStatus[]; @@ -54,6 +56,7 @@ type AddJobProps = { jobTitles: JobTitle[]; locations: JobLocation[]; jobSources: JobSource[]; + tags: Tag[]; editJob?: JobResponse | null; resetEditJob: () => void; }; @@ -64,12 +67,14 @@ export function AddJob({ jobTitles, locations, jobSources, + tags, editJob, resetEditJob, }: AddJobProps) { const [dialogOpen, setDialogOpen] = useState(false); const [resumeDialogOpen, setResumeDialogOpen] = useState(false); const [resumes, setResumes] = useState([]); + const [availableTags, setAvailableTags] = useState(tags); const [isPending, startTransition] = useTransition(); const form = useForm>({ resolver: zodResolver(AddJobFormSchema) as any, @@ -113,9 +118,18 @@ export function AddJob({ jobUrl: editJob.jobUrl ?? undefined, dateApplied: editJob.appliedDate ?? undefined, resume: editJob.Resume?.id ?? undefined, + tags: editJob.tags?.map((t) => t.id) ?? [], }, { keepDefaultValues: true }, ); + // Merge any tags from editJob into the local pool so they're selectable + if (editJob.tags && editJob.tags.length > 0) { + setAvailableTags((prev) => { + const existing = new Set(prev.map((t) => t.id)); + const incoming = editJob.tags!.filter((t) => !existing.has(t.id)); + return incoming.length > 0 ? [...prev, ...incoming] : prev; + }); + } setDialogOpen(true); } }, [editJob, reset]); @@ -475,6 +489,27 @@ export function AddJob({ />

+ {/* Add Skill Tags */} +
+ ( + + Add Skill + + field.onChange(ids)} + /> + + + + )} + /> +
+ {/* Job Description */}
- {editJob && ( - - )} + {editJob && }
{job.Status?.label} @@ -106,6 +106,15 @@ function JobDetails({ job }: { job: JobResponse }) { {job?.appliedDate ? format(new Date(job?.appliedDate), "PP") : ""} + {job.tags && job.tags.length > 0 && ( +
+ {job.tags.map((tag) => ( + + {tag.label} + + ))} +
+ )} {job.jobUrl && (
Job URL: diff --git a/src/components/myjobs/JobsContainer.tsx b/src/components/myjobs/JobsContainer.tsx index f94a3ff..4a23867 100644 --- a/src/components/myjobs/JobsContainer.tsx +++ b/src/components/myjobs/JobsContainer.tsx @@ -24,6 +24,7 @@ import { JobSource, JobStatus, JobTitle, + Tag, } from "@/models/job.model"; import { Select, @@ -51,6 +52,7 @@ type MyJobsProps = { titles: JobTitle[]; locations: JobLocation[]; sources: JobSource[]; + tags: Tag[]; }; function JobsContainer({ @@ -59,6 +61,7 @@ function JobsContainer({ titles, locations, sources, + tags, }: MyJobsProps) { const router = useRouter(); const pathname = usePathname(); @@ -70,7 +73,7 @@ function JobsContainer({ return params.toString(); }, - [queryParams] + [queryParams], ); const [jobs, setJobs] = useState([]); const [page, setPage] = useState(1); @@ -95,7 +98,7 @@ function JobsContainer({ page, jobsPerPage, filter, - search + search, ); if (success && data) { setJobs((prev) => (page === 1 ? data : [...prev, ...data])); @@ -112,7 +115,7 @@ function JobsContainer({ return; } }, - [jobsPerPage] + [jobsPerPage], ); const reloadJobs = useCallback(async () => { @@ -292,6 +295,7 @@ function JobsContainer({ jobTitles={titles} locations={locations} jobSources={sources} + tags={tags} editJob={editJob} resetEditJob={resetEditJob} /> @@ -329,7 +333,9 @@ function JobsContainer({ + + + + + + {filteredOptions.length === 0 && !inputValue && ( + No skills found. + )} + {filteredOptions.length > 0 && ( + + {filteredOptions.map((tag) => ( + handleSelect(tag.id)} + > + {tag.label} + + ))} + + )} + {inputValue.trim() && !exactMatchExists && ( + + + {isPending ? ( + + ) : ( + + )} + Create "{inputValue.trim()}" + + + )} + + + + + + {selectedTags.length > 0 && ( +
+ {selectedTags.map((tag) => ( + + {tag.label} + + + ))} +
+ )} +
+ ); +} diff --git a/src/models/addJobForm.schema.ts b/src/models/addJobForm.schema.ts index 1469d09..4ad9e86 100644 --- a/src/models/addJobForm.schema.ts +++ b/src/models/addJobForm.schema.ts @@ -58,4 +58,5 @@ export const AddJobFormSchema = z.object({ jobUrl: z.string().optional(), applied: z.boolean().default(false), resume: z.string().optional(), + tags: z.array(z.string()).max(10).optional().default([]), }); diff --git a/src/models/job.model.ts b/src/models/job.model.ts index 3e0ee4a..44350fd 100644 --- a/src/models/job.model.ts +++ b/src/models/job.model.ts @@ -17,6 +17,16 @@ export interface JobForm { applied: boolean; } +export interface Tag { + id: string; + label: string; + value: string; + createdBy: string; + _count?: { + jobs: number; + }; +} + export interface JobResponse { id: string; userId: string; @@ -37,6 +47,7 @@ export interface JobResponse { Resume?: Resume; matchScore?: number | null; matchData?: string | null; + tags?: Tag[]; _count?: { Notes?: number }; } From 1242b2863b0aadc0b7ce507d14cdfcf4ac916fca Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Mon, 2 Mar 2026 19:13:23 -0700 Subject: [PATCH 05/12] chore: write unit tests for skill tags feature --- __tests__/JobDetails.spec.tsx | 123 +++++++++++++++ __tests__/TagInput.spec.tsx | 277 ++++++++++++++++++++++++++++++++++ __tests__/tag.actions.spec.ts | 248 ++++++++++++++++++++++++++++++ 3 files changed, 648 insertions(+) create mode 100644 __tests__/JobDetails.spec.tsx create mode 100644 __tests__/TagInput.spec.tsx create mode 100644 __tests__/tag.actions.spec.ts diff --git a/__tests__/JobDetails.spec.tsx b/__tests__/JobDetails.spec.tsx new file mode 100644 index 0000000..f0ece3d --- /dev/null +++ b/__tests__/JobDetails.spec.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import JobDetails from "@/components/myjobs/JobDetails"; +import { JobResponse, Tag } from "@/models/job.model"; +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; + +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(() => ({ back: jest.fn() })), +})); + +// Stub heavy sub-components that are not under test +jest.mock("@/components/profile/AiJobMatchSection", () => ({ + AiJobMatchSection: () => null, +})); + +jest.mock("@/components/myjobs/NotesSection", () => ({ + NotesSection: () => null, +})); + +jest.mock("@/components/TipTapContentViewer", () => ({ + TipTapContentViewer: () => null, +})); + +jest.mock("@/components/automations/MatchDetails", () => ({ + MatchDetails: () => null, +})); + +jest.mock("@/components/profile/DownloadFileButton", () => ({ + DownloadFileButton: () => null, +})); + +const makeJob = (overrides: Partial = {}): JobResponse => ({ + id: "job-1", + userId: "user-1", + JobTitle: { + id: "t1", + label: "Frontend Developer", + value: "frontend developer", + createdBy: "user-1", + }, + Company: { + id: "c1", + label: "Acme Corp", + value: "acme corp", + createdBy: "user-1", + }, + Status: { id: "s1", label: "Applied", value: "applied" }, + Location: { id: "l1", label: "Remote", value: "remote", createdBy: "user-1" }, + JobSource: { + id: "src1", + label: "LinkedIn", + value: "linkedin", + createdBy: "user-1", + }, + jobType: "FT", + createdAt: new Date("2025-01-01"), + appliedDate: new Date("2025-01-15"), + dueDate: new Date("2099-12-31"), // far future — not expired + salaryRange: "3", + description: "

Job description

", + jobUrl: "", + applied: true, + tags: [], + ...overrides, +}); + +describe("JobDetails – skill badges", () => { + it("renders skill badges for all tags on the job", () => { + const tags: Tag[] = [ + { id: "tag-1", label: "React", value: "react", createdBy: "user-1" }, + { + id: "tag-2", + label: "TypeScript", + value: "typescript", + createdBy: "user-1", + }, + { id: "tag-3", label: "Node.js", value: "node.js", createdBy: "user-1" }, + ]; + render(); + + expect(screen.getByText("React")).toBeInTheDocument(); + expect(screen.getByText("TypeScript")).toBeInTheDocument(); + expect(screen.getByText("Node.js")).toBeInTheDocument(); + }); + + it("renders no tag badges when the job has no tags", () => { + render(); + + // Verify tag area is simply absent; badges for these labels shouldn't exist + expect(screen.queryByText("React")).not.toBeInTheDocument(); + expect(screen.queryByText("TypeScript")).not.toBeInTheDocument(); + }); + + it("renders no tag badges when tags property is undefined", () => { + const job = makeJob(); + delete job.tags; + render(); + + // Should render without crashing and show no skill badges + expect(screen.getByText("Frontend Developer")).toBeInTheDocument(); + }); + + it("renders a single skill badge correctly", () => { + const tags: Tag[] = [ + { id: "tag-1", label: "GraphQL", value: "graphql", createdBy: "user-1" }, + ]; + render(); + + expect(screen.getByText("GraphQL")).toBeInTheDocument(); + }); + + it("renders each tag label exactly once", () => { + const tags: Tag[] = [ + { id: "tag-1", label: "React", value: "react", createdBy: "user-1" }, + { id: "tag-2", label: "Vue", value: "vue", createdBy: "user-1" }, + ]; + render(); + + // getAllByText returns an array; each label should appear exactly once in the badge area + expect(screen.getAllByText("React")).toHaveLength(1); + expect(screen.getAllByText("Vue")).toHaveLength(1); + }); +}); diff --git a/__tests__/TagInput.spec.tsx b/__tests__/TagInput.spec.tsx new file mode 100644 index 0000000..bcdd970 --- /dev/null +++ b/__tests__/TagInput.spec.tsx @@ -0,0 +1,277 @@ +import React, { useState } from "react"; +import { TagInput } from "@/components/myjobs/TagInput"; +import { Tag } from "@/models/job.model"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createTag } from "@/actions/tag.actions"; + +jest.mock("@/actions/tag.actions", () => ({ + createTag: jest.fn(), +})); + +jest.mock("@/components/ui/use-toast", () => ({ + toast: jest.fn(), +})); + +// Required by Radix UI Popover / Command components +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +window.HTMLElement.prototype.scrollIntoView = jest.fn(); +window.HTMLElement.prototype.hasPointerCapture = jest.fn(); + +document.createRange = () => { + const range = new Range(); + range.getBoundingClientRect = jest.fn().mockReturnValue({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + }); + range.getClientRects = () => ({ + item: () => null, + length: 0, + [Symbol.iterator]: jest.fn(), + }); + return range; +}; + +// Controlled wrapper so we can track state changes +function ControlledTagInput({ + availableTags, + initialIds = [], +}: { + availableTags: Tag[]; + initialIds?: string[]; +}) { + const [selectedIds, setSelectedIds] = useState(initialIds); + return ( + + ); +} + +const MOCK_TAGS: Tag[] = [ + { id: "tag-1", label: "React", value: "react", createdBy: "user-1" }, + { + id: "tag-2", + label: "TypeScript", + value: "typescript", + createdBy: "user-1", + }, + { id: "tag-3", label: "Node.js", value: "node.js", createdBy: "user-1" }, +]; + +describe("TagInput Component", () => { + const user = userEvent.setup({ skipHover: true }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders the trigger button with default placeholder text", () => { + render(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByText("Search or add a skill...")).toBeInTheDocument(); + }); + + it("opens the dropdown and shows available tags when the trigger is clicked", async () => { + render(); + + const trigger = screen.getByRole("combobox"); + await user.click(trigger); + + await waitFor(() => { + expect(screen.getByText("React")).toBeInTheDocument(); + expect(screen.getByText("TypeScript")).toBeInTheDocument(); + expect(screen.getByText("Node.js")).toBeInTheDocument(); + }); + }); + + it("selects a tag when an option is clicked and renders it as a badge", async () => { + render(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByRole("option", { name: "React" })); + + await waitFor(() => { + // Badge should appear in the selected tags area + const badges = screen.getAllByText("React"); + expect(badges.length).toBeGreaterThan(0); + }); + }); + + it("closes the popover after selecting an existing tag", async () => { + render(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByRole("option", { name: "TypeScript" })); + + await waitFor(() => { + // Options list should be gone + expect( + screen.queryByRole("option", { name: "React" }), + ).not.toBeInTheDocument(); + }); + }); + + it("excludes already-selected tags from the dropdown options", async () => { + render( + , + ); + + await user.click(screen.getByRole("combobox")); + + await waitFor(() => { + expect( + screen.queryByRole("option", { name: "React" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "TypeScript" }), + ).toBeInTheDocument(); + }); + }); + + it("removes a tag badge when the remove button is clicked", async () => { + render( + , + ); + + const removeReactBtn = screen.getByRole("button", { + name: /remove react/i, + }); + await user.click(removeReactBtn); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: /remove react/i }), + ).not.toBeInTheDocument(); + // TypeScript badge should still be present + expect( + screen.getByRole("button", { name: /remove typescript/i }), + ).toBeInTheDocument(); + }); + }); + + it("renders selected tag badges for pre-selected tags", () => { + render( + , + ); + + expect( + screen.getByRole("button", { name: /remove react/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /remove node.js/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /remove typescript/i }), + ).not.toBeInTheDocument(); + }); + + it("shows a create option when typed value has no exact match", async () => { + render(); + + await user.click(screen.getByRole("combobox")); + await user.type(screen.getByPlaceholderText("Type a skill..."), "GraphQL"); + + await waitFor(() => { + expect(screen.getByText(/Create "GraphQL"/i)).toBeInTheDocument(); + }); + }); + + it("hides the create option when the typed value exactly matches an existing tag", async () => { + render(); + + await user.click(screen.getByRole("combobox")); + await user.type(screen.getByPlaceholderText("Type a skill..."), "react"); + + await waitFor(() => { + expect(screen.queryByText(/Create "react"/i)).not.toBeInTheDocument(); + }); + }); + + it("calls createTag with the typed label and closes the popover on success", async () => { + const newTag: Tag = { + id: "tag-99", + label: "GraphQL", + value: "graphql", + createdBy: "user-1", + }; + (createTag as jest.Mock).mockResolvedValue({ success: true, data: newTag }); + + render(); + + await user.click(screen.getByRole("combobox")); + await user.type(screen.getByPlaceholderText("Type a skill..."), "GraphQL"); + await user.click(await screen.findByText(/Create "GraphQL"/i)); + + await waitFor(() => { + expect(createTag).toHaveBeenCalledWith("GraphQL"); + // Popover should be closed + expect( + screen.queryByRole("option", { name: "React" }), + ).not.toBeInTheDocument(); + }); + }); + + it("shows a toast error and keeps the popover open when createTag fails", async () => { + const { toast } = require("@/components/ui/use-toast"); + (createTag as jest.Mock).mockResolvedValue({ + success: false, + message: "Server error", + }); + + render(); + + await user.click(screen.getByRole("combobox")); + await user.type(screen.getByPlaceholderText("Type a skill..."), "GraphQL"); + await user.click(await screen.findByText(/Create "GraphQL"/i)); + + await waitFor(() => { + expect(createTag).toHaveBeenCalledWith("GraphQL"); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + description: "Server error", + }), + ); + }); + }); + + it("disables the trigger and shows max-reached message when 10 tags are selected", () => { + const tenTagIds = Array.from({ length: 10 }, (_, i) => `tag-${i + 100}`); + const tenTags: Tag[] = tenTagIds.map((id, i) => ({ + id, + label: `Skill ${i + 1}`, + value: `skill-${i + 1}`, + createdBy: "user-1", + })); + + render( + , + ); + + const trigger = screen.getByRole("combobox"); + expect(trigger).toBeDisabled(); + expect(screen.getByText("Max 10 skills reached")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/tag.actions.spec.ts b/__tests__/tag.actions.spec.ts new file mode 100644 index 0000000..0c792f6 --- /dev/null +++ b/__tests__/tag.actions.spec.ts @@ -0,0 +1,248 @@ +import { + getAllTags, + getTagList, + createTag, + deleteTagById, +} from "@/actions/tag.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +jest.mock("@prisma/client", () => { + const mPrismaClient = { + tag: { + findMany: jest.fn(), + count: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + }, + job: { + count: jest.fn(), + }, + }; + return { PrismaClient: jest.fn(() => mPrismaClient) }; +}); + +jest.mock("@/utils/user.utils", () => ({ + getCurrentUser: jest.fn(), +})); + +describe("Tag Actions", () => { + const mockUser = { id: "user-id" }; + + const mockTag = { + id: "tag-1", + label: "React", + value: "react", + createdBy: mockUser.id, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // getAllTags + describe("getAllTags", () => { + it("should return all tags for the authenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + const mockTags = [ + mockTag, + { ...mockTag, id: "tag-2", label: "TypeScript", value: "typescript" }, + ]; + (prisma.tag.findMany as jest.Mock).mockResolvedValue(mockTags); + + const result = await getAllTags(); + + expect(result).toEqual(mockTags); + expect(prisma.tag.findMany).toHaveBeenCalledWith({ + where: { createdBy: mockUser.id }, + orderBy: { label: "asc" }, + }); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await getAllTags(); + + expect(result).toEqual({ success: false, message: "Not authenticated" }); + expect(prisma.tag.findMany).not.toHaveBeenCalled(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.tag.findMany as jest.Mock).mockRejectedValue( + new Error("DB error"), + ); + + const result = await getAllTags(); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); + + // getTagList + describe("getTagList", () => { + it("should return paginated tag list with counts", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + const mockData = [{ ...mockTag, _count: { jobs: 3 } }]; + (prisma.tag.findMany as jest.Mock).mockResolvedValue(mockData); + (prisma.tag.count as jest.Mock).mockResolvedValue(1); + + const result = await getTagList(1, 10); + + expect(result).toEqual({ data: mockData, total: 1 }); + expect(prisma.tag.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { createdBy: mockUser.id }, + skip: 0, + take: 10, + orderBy: { label: "asc" }, + }), + ); + }); + + it("should calculate skip correctly for page 2", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.tag.findMany as jest.Mock).mockResolvedValue([]); + (prisma.tag.count as jest.Mock).mockResolvedValue(0); + + await getTagList(2, 10); + + expect(prisma.tag.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 10, take: 10 }), + ); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await getTagList(1, 10); + + expect(result).toEqual({ success: false, message: "Not authenticated" }); + expect(prisma.tag.findMany).not.toHaveBeenCalled(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.tag.findMany as jest.Mock).mockRejectedValue( + new Error("DB error"), + ); + + const result = await getTagList(1, 10); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); + + // createTag + describe("createTag", () => { + it("should upsert and return the tag on success", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.tag.upsert as jest.Mock).mockResolvedValue(mockTag); + + const result = await createTag("React"); + + expect(result).toEqual({ data: mockTag, success: true }); + expect(prisma.tag.upsert).toHaveBeenCalledWith({ + where: { value_createdBy: { value: "react", createdBy: mockUser.id } }, + update: {}, + create: { label: "React", value: "react", createdBy: mockUser.id }, + }); + }); + + it("should trim label and lowercase the value", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.tag.upsert as jest.Mock).mockResolvedValue(mockTag); + + await createTag(" React "); + + expect(prisma.tag.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ label: "React", value: "react" }), + }), + ); + }); + + it("should return error for empty label", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + + const result = await createTag(" "); + + expect(result).toEqual({ + success: false, + message: "Tag label cannot be empty.", + }); + expect(prisma.tag.upsert).not.toHaveBeenCalled(); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await createTag("React"); + + expect(result).toEqual({ success: false, message: "Not authenticated" }); + expect(prisma.tag.upsert).not.toHaveBeenCalled(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.tag.upsert as jest.Mock).mockRejectedValue(new Error("DB error")); + + const result = await createTag("React"); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); + + // deleteTagById + describe("deleteTagById", () => { + it("should delete a tag that has no linked jobs", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.count as jest.Mock).mockResolvedValue(0); + (prisma.tag.delete as jest.Mock).mockResolvedValue(mockTag); + + const result = await deleteTagById("tag-1"); + + expect(result).toEqual({ res: mockTag, success: true }); + expect(prisma.tag.delete).toHaveBeenCalledWith({ + where: { id: "tag-1", createdBy: mockUser.id }, + }); + }); + + it("should return error when tag is linked to one or more jobs", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.count as jest.Mock).mockResolvedValue(3); + + const result = await deleteTagById("tag-1"); + + expect(result).toEqual({ + success: false, + message: + "Skill tag cannot be deleted because it is linked to 3 job(s).", + }); + expect(prisma.tag.delete).not.toHaveBeenCalled(); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await deleteTagById("tag-1"); + + expect(result).toEqual({ success: false, message: "Not authenticated" }); + expect(prisma.job.count).not.toHaveBeenCalled(); + expect(prisma.tag.delete).not.toHaveBeenCalled(); + }); + + it("should handle database errors during deletion", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.count as jest.Mock).mockResolvedValue(0); + (prisma.tag.delete as jest.Mock).mockRejectedValue(new Error("DB error")); + + const result = await deleteTagById("tag-1"); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); +}); From 95bc04677cbc4b796136f44de7d163f99b5fcb61 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Wed, 4 Mar 2026 14:49:38 -0700 Subject: [PATCH 06/12] Show total hours of weekly activities on dashboard --- .../dashboard/WeeklyBarChartToggle.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/WeeklyBarChartToggle.tsx b/src/components/dashboard/WeeklyBarChartToggle.tsx index e2a314f..e175f17 100644 --- a/src/components/dashboard/WeeklyBarChartToggle.tsx +++ b/src/components/dashboard/WeeklyBarChartToggle.tsx @@ -33,13 +33,34 @@ export default function WeeklyBarChartToggle({ return newItem; }); + const totalHours = + current.label === "Activities" + ? roundedData.reduce( + (sum, item) => + sum + + current.keys.reduce( + (keySum, key) => + keySum + (typeof item[key] === "number" ? item[key] : 0), + 0, + ), + 0, + ) + : null; + return (
- - Weekly {current.label} - +
+ + Weekly {current.label} + + {totalHours !== null && ( + + {totalHours.toFixed(1)} hrs + + )} +
{charts.map((chart, index) => ( + + + { + e.stopPropagation(); + onEdit(question); + }} + > + + Edit + + { + e.stopPropagation(); + setShowDeleteDialog(true); + }} + > + + Delete + + + +
+ + {hasAnswer && ( +
+ )} + + {question.tags && question.tags.length > 0 && ( +
+ {question.tags.map((tag) => ( + + {tag.label} + + ))} +
+ )} + + {isLongAnswer && ( + + )} +
+ + + + + Delete Question + + Are you sure you want to delete this question? This action cannot + be undone. + + + + Cancel + onDelete(question.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + ); +} diff --git a/src/components/questions/QuestionForm.tsx b/src/components/questions/QuestionForm.tsx new file mode 100644 index 0000000..42fcb28 --- /dev/null +++ b/src/components/questions/QuestionForm.tsx @@ -0,0 +1,192 @@ +"use client"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogOverlay, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { createQuestion, updateQuestion } from "@/actions/question.actions"; +import { Loader } from "lucide-react"; +import { Button } from "../ui/button"; +import { useForm } from "react-hook-form"; +import { useEffect, useTransition } from "react"; +import { AddQuestionFormSchema } from "@/models/addQuestionForm.schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Question } from "@/models/question.model"; +import { Tag } from "@/models/job.model"; +import { z } from "zod"; +import { toast } from "../ui/use-toast"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../ui/form"; +import { Input } from "../ui/input"; +import TiptapEditor from "../TiptapEditor"; +import { TagInput } from "../myjobs/TagInput"; + +type QuestionFormProps = { + availableTags: Tag[]; + editQuestion?: Question | null; + resetEditQuestion: () => void; + onQuestionSaved: () => void; + dialogOpen: boolean; + setDialogOpen: (open: boolean) => void; +}; + +export function QuestionForm({ + availableTags, + editQuestion, + resetEditQuestion, + onQuestionSaved, + dialogOpen, + setDialogOpen, +}: QuestionFormProps) { + const [isPending, startTransition] = useTransition(); + const form = useForm>({ + resolver: zodResolver(AddQuestionFormSchema), + defaultValues: { + question: "", + answer: "", + tagIds: [], + }, + }); + + const { reset } = form; + + useEffect(() => { + if (editQuestion) { + reset({ + id: editQuestion.id, + question: editQuestion.question, + answer: editQuestion.answer || "", + tagIds: editQuestion.tags?.map((t) => t.id) || [], + }); + } else { + reset({ + question: "", + answer: "", + tagIds: [], + }); + } + }, [editQuestion, reset]); + + function onSubmit(data: z.infer) { + startTransition(async () => { + const { success, message } = editQuestion + ? await updateQuestion(data) + : await createQuestion(data); + + if (success) { + toast({ + variant: "success", + description: `Question has been ${editQuestion ? "updated" : "created"} successfully`, + }); + reset(); + setDialogOpen(false); + resetEditQuestion(); + onQuestionSaved(); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: message, + }); + } + }); + } + + const pageTitle = editQuestion ? "Edit Question" : "Add Question"; + + const closeDialog = () => { + reset(); + resetEditQuestion(); + setDialogOpen(false); + }; + + return ( + + + + + {pageTitle} + +
+ + ( + + Question * + + + + + + )} + /> + + ( + + Skill Tags + + + + + + )} + /> + + ( + + Answer + + + + + + )} + /> + + + + + + + +
+
+
+ ); +} diff --git a/src/components/questions/QuestionList.tsx b/src/components/questions/QuestionList.tsx new file mode 100644 index 0000000..54ad1e3 --- /dev/null +++ b/src/components/questions/QuestionList.tsx @@ -0,0 +1,32 @@ +"use client"; +import { Question } from "@/models/question.model"; +import { QuestionCard } from "./QuestionCard"; + +type QuestionListProps = { + questions: Question[]; + onEdit: (question: Question) => void; + onDelete: (questionId: string) => void; +}; + +export function QuestionList({ questions, onEdit, onDelete }: QuestionListProps) { + if (questions.length === 0) { + return ( +
+ No questions found. Create your first question to get started. +
+ ); + } + + return ( +
+ {questions.map((question) => ( + + ))} +
+ ); +} diff --git a/src/components/questions/QuestionsContainer.tsx b/src/components/questions/QuestionsContainer.tsx new file mode 100644 index 0000000..f120042 --- /dev/null +++ b/src/components/questions/QuestionsContainer.tsx @@ -0,0 +1,228 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Button } from "../ui/button"; +import { PlusCircle, Search } from "lucide-react"; +import { Input } from "../ui/input"; +import { + deleteQuestion, + getQuestionById, + getQuestionsList, +} from "@/actions/question.actions"; +import { toast } from "../ui/use-toast"; +import { Question } from "@/models/question.model"; +import { Tag } from "@/models/job.model"; +import { RecordsPerPageSelector } from "../RecordsPerPageSelector"; +import { RecordsCount } from "../RecordsCount"; +import { APP_CONSTANTS } from "@/lib/constants"; +import Loading from "../Loading"; +import { QuestionList } from "./QuestionList"; +import { QuestionForm } from "./QuestionForm"; + +type QuestionsContainerProps = { + availableTags: Tag[]; + filterKey?: string; + onQuestionsChanged?: () => void; +}; + +function QuestionsContainer({ + availableTags, + filterKey, + onQuestionsChanged, +}: QuestionsContainerProps) { + const [questions, setQuestions] = useState([]); + const [page, setPage] = useState(1); + const [totalQuestions, setTotalQuestions] = useState(0); + const [editQuestion, setEditQuestion] = useState(null); + const [loading, setLoading] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [recordsPerPage, setRecordsPerPage] = useState( + APP_CONSTANTS.RECORDS_PER_PAGE + ); + const [searchTerm, setSearchTerm] = useState(""); + const hasSearched = useRef(false); + + const loadQuestions = useCallback( + async (pageNum: number, filter?: string, search?: string) => { + setLoading(true); + const result = await getQuestionsList( + pageNum, + recordsPerPage, + filter, + search + ); + if (result?.success && result.data) { + setQuestions((prev) => + pageNum === 1 ? result.data : [...prev, ...result.data] + ); + setTotalQuestions(result.total); + setPage(pageNum); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: result?.message || "Failed to load questions.", + }); + } + setLoading(false); + }, + [recordsPerPage] + ); + + const reloadQuestions = useCallback(async () => { + await loadQuestions(1, filterKey, searchTerm || undefined); + onQuestionsChanged?.(); + }, [loadQuestions, filterKey, searchTerm, onQuestionsChanged]); + + const onDeleteQuestion = async (questionId: string) => { + const { success, message } = await deleteQuestion(questionId); + if (success) { + toast({ + variant: "success", + description: "Question has been deleted successfully", + }); + reloadQuestions(); + } else { + toast({ + variant: "destructive", + title: "Error!", + description: message, + }); + } + }; + + const onEditQuestion = async (question: Question) => { + const { data, success, message } = await getQuestionById(question.id); + if (!success) { + toast({ + variant: "destructive", + title: "Error!", + description: message, + }); + return; + } + setEditQuestion(data); + setDialogOpen(true); + }; + + const addQuestionForm = () => { + setEditQuestion(null); + setDialogOpen(true); + }; + + useEffect(() => { + loadQuestions(1, filterKey, searchTerm || undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadQuestions, filterKey, recordsPerPage]); + + // Debounced search + useEffect(() => { + if (searchTerm !== "") { + hasSearched.current = true; + } + if (searchTerm === "" && !hasSearched.current) return; + + const timer = setTimeout(() => { + loadQuestions(1, filterKey, searchTerm || undefined); + }, 300); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchTerm]); + + return ( + <> + + + Question Bank +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+
+
+ + {loading && } + {!loading && ( + <> + + {questions.length > 0 && ( +
+ + {totalQuestions > APP_CONSTANTS.RECORDS_PER_PAGE && ( + + )} +
+ )} + + )} + {!loading && questions.length < totalQuestions && ( +
+ +
+ )} +
+ +
+ setEditQuestion(null)} + onQuestionSaved={reloadQuestions} + dialogOpen={dialogOpen} + setDialogOpen={setDialogOpen} + /> + + ); +} + +export default QuestionsContainer; diff --git a/src/components/questions/QuestionsSidebar.tsx b/src/components/questions/QuestionsSidebar.tsx new file mode 100644 index 0000000..9332448 --- /dev/null +++ b/src/components/questions/QuestionsSidebar.tsx @@ -0,0 +1,70 @@ +"use client"; +import { cn } from "@/lib/utils"; + +type TagWithCount = { + id: string; + label: string; + value: string; + questionCount: number; +}; + +type QuestionsSidebarProps = { + tags: TagWithCount[]; + totalQuestions: number; + selectedFilter?: string; + onFilterChange: (filter: string | undefined) => void; +}; + +function QuestionsSidebar({ + tags, + totalQuestions, + selectedFilter, + onFilterChange, +}: QuestionsSidebarProps) { + return ( +
+

Skill Tags

+
    +
  • + +
  • + {tags.map((tag) => ( +
  • + +
  • + ))} +
+
+ ); +} + +export default QuestionsSidebar; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 52ab5e5..d5e392d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -7,6 +7,7 @@ import { Sheet, Wrench, Zap, + BookOpen, } from "lucide-react"; export const APP_CONSTANTS = { @@ -70,6 +71,11 @@ export const SIDEBAR_LINKS = [ route: "/dashboard/activities", label: "Activities", }, + { + icon: BookOpen, + route: "/dashboard/questions", + label: "Question Bank", + }, { icon: UserRound, route: "/dashboard/profile", diff --git a/src/models/addQuestionForm.schema.ts b/src/models/addQuestionForm.schema.ts new file mode 100644 index 0000000..22a9ff4 --- /dev/null +++ b/src/models/addQuestionForm.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const AddQuestionFormSchema = z.object({ + id: z.string().optional(), + question: z + .string({ error: "Question is required." }) + .min(2, { message: "Question must be at least 2 characters." }), + answer: z + .string() + .max(5000, { message: "Answer cannot exceed 5000 characters." }) + .optional() + .nullable(), + tagIds: z + .array(z.string()) + .max(10, { message: "Maximum 10 skill tags allowed." }) + .optional(), +}); diff --git a/src/models/question.model.ts b/src/models/question.model.ts new file mode 100644 index 0000000..00a18d2 --- /dev/null +++ b/src/models/question.model.ts @@ -0,0 +1,11 @@ +import { Tag } from "./job.model"; + +export interface Question { + id: string; + question: string; + answer?: string | null; + createdBy: string; + tags: Tag[]; + createdAt: Date; + updatedAt: Date; +} From 86e785055e6588c00fc9baa0111f48cb5a778651 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Thu, 5 Mar 2026 11:13:35 -0700 Subject: [PATCH 09/12] chore: Write unit tests for question bank feature --- __tests__/addQuestionForm.schema.spec.ts | 93 ++++ __tests__/question.actions.spec.ts | 518 +++++++++++++++++++++++ 2 files changed, 611 insertions(+) create mode 100644 __tests__/addQuestionForm.schema.spec.ts create mode 100644 __tests__/question.actions.spec.ts diff --git a/__tests__/addQuestionForm.schema.spec.ts b/__tests__/addQuestionForm.schema.spec.ts new file mode 100644 index 0000000..d8a8876 --- /dev/null +++ b/__tests__/addQuestionForm.schema.spec.ts @@ -0,0 +1,93 @@ +import { AddQuestionFormSchema } from "@/models/addQuestionForm.schema"; + +describe("AddQuestionFormSchema", () => { + describe("valid data", () => { + it("should accept valid question with all fields", () => { + const data = { + question: "What is React?", + answer: "A JavaScript library for building UIs.", + tagIds: ["tag-1", "tag-2"], + }; + const result = AddQuestionFormSchema.parse(data); + expect(result.question).toBe("What is React?"); + expect(result.answer).toBe("A JavaScript library for building UIs."); + expect(result.tagIds).toEqual(["tag-1", "tag-2"]); + }); + + it("should accept question without answer", () => { + const data = { question: "What is React?" }; + const result = AddQuestionFormSchema.parse(data); + expect(result.question).toBe("What is React?"); + expect(result.answer).toBeUndefined(); + }); + + it("should accept null answer", () => { + const data = { question: "What is React?", answer: null }; + const result = AddQuestionFormSchema.parse(data); + expect(result.answer).toBeNull(); + }); + + it("should accept question without tagIds", () => { + const data = { question: "What is React?" }; + const result = AddQuestionFormSchema.parse(data); + expect(result.tagIds).toBeUndefined(); + }); + + it("should accept empty tagIds array", () => { + const data = { question: "What is React?", tagIds: [] }; + const result = AddQuestionFormSchema.parse(data); + expect(result.tagIds).toEqual([]); + }); + + it("should accept optional id for editing", () => { + const data = { id: "q-123", question: "Updated question?" }; + const result = AddQuestionFormSchema.parse(data); + expect(result.id).toBe("q-123"); + }); + + it("should accept minimum length question (2 chars)", () => { + const data = { question: "ab" }; + const result = AddQuestionFormSchema.parse(data); + expect(result.question).toBe("ab"); + }); + + it("should accept up to 10 tags", () => { + const tagIds = Array.from({ length: 10 }, (_, i) => `tag-${i}`); + const data = { question: "What is React?", tagIds }; + const result = AddQuestionFormSchema.parse(data); + expect(result.tagIds).toHaveLength(10); + }); + }); + + describe("invalid data", () => { + it("should reject question shorter than 2 characters", () => { + const data = { question: "a" }; + expect(() => AddQuestionFormSchema.parse(data)).toThrow(); + }); + + it("should reject empty question", () => { + const data = { question: "" }; + expect(() => AddQuestionFormSchema.parse(data)).toThrow(); + }); + + it("should reject missing question", () => { + const data = { answer: "Some answer" }; + expect(() => AddQuestionFormSchema.parse(data)).toThrow(); + }); + + it("should reject answer exceeding 5000 characters", () => { + const data = { question: "What?", answer: "x".repeat(5001) }; + expect(() => AddQuestionFormSchema.parse(data)).toThrow(); + }); + + it("should reject more than 10 tags", () => { + const tagIds = Array.from({ length: 11 }, (_, i) => `tag-${i}`); + const data = { question: "What?", tagIds }; + expect(() => AddQuestionFormSchema.parse(data)).toThrow(); + }); + + it("should reject empty object", () => { + expect(() => AddQuestionFormSchema.parse({})).toThrow(); + }); + }); +}); diff --git a/__tests__/question.actions.spec.ts b/__tests__/question.actions.spec.ts new file mode 100644 index 0000000..57b8694 --- /dev/null +++ b/__tests__/question.actions.spec.ts @@ -0,0 +1,518 @@ +import { + getQuestionsList, + getQuestionById, + createQuestion, + updateQuestion, + deleteQuestion, + getTagsWithQuestionCounts, +} from "@/actions/question.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +jest.mock("@prisma/client", () => { + const mPrismaClient = { + question: { + findMany: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }, + tag: { + findMany: jest.fn(), + }, + }; + return { PrismaClient: jest.fn(() => mPrismaClient) }; +}); + +jest.mock("@/utils/user.utils", () => ({ + getCurrentUser: jest.fn(), +})); + +describe("Question Actions", () => { + const mockUser = { id: "user-id" }; + + const mockQuestion = { + id: "q-1", + question: "What is React?", + answer: "

A JS library

", + createdBy: mockUser.id, + tags: [{ id: "tag-1", label: "React", value: "react" }], + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // getQuestionsList + describe("getQuestionsList", () => { + it("should return paginated questions", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.findMany as jest.Mock).mockResolvedValue([mockQuestion]); + (prisma.question.count as jest.Mock).mockResolvedValue(1); + + const result = await getQuestionsList(1, 10); + + expect(result).toEqual({ success: true, data: [mockQuestion], total: 1 }); + expect(prisma.question.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { createdBy: mockUser.id }, + skip: 0, + take: 10, + orderBy: [{ createdAt: "desc" }], + include: { tags: true }, + }), + ); + }); + + it("should calculate offset for page 2", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.findMany as jest.Mock).mockResolvedValue([]); + (prisma.question.count as jest.Mock).mockResolvedValue(0); + + await getQuestionsList(2, 10); + + expect(prisma.question.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 10, take: 10 }), + ); + }); + + it("should filter by tag when filter is provided", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.findMany as jest.Mock).mockResolvedValue([]); + (prisma.question.count as jest.Mock).mockResolvedValue(0); + + await getQuestionsList(1, 10, "tag-1"); + + expect(prisma.question.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + createdBy: mockUser.id, + tags: { some: { id: "tag-1" } }, + }, + }), + ); + }); + + it("should search by question and answer content", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.findMany as jest.Mock).mockResolvedValue([]); + (prisma.question.count as jest.Mock).mockResolvedValue(0); + + await getQuestionsList(1, 10, undefined, "react"); + + expect(prisma.question.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + createdBy: mockUser.id, + OR: [ + { question: { contains: "react" } }, + { answer: { contains: "react" } }, + ], + }, + }), + ); + }); + + it("should apply both filter and search together", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.findMany as jest.Mock).mockResolvedValue([]); + (prisma.question.count as jest.Mock).mockResolvedValue(0); + + await getQuestionsList(1, 10, "tag-1", "react"); + + expect(prisma.question.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + createdBy: mockUser.id, + tags: { some: { id: "tag-1" } }, + OR: [ + { question: { contains: "react" } }, + { answer: { contains: "react" } }, + ], + }, + }), + ); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await getQuestionsList(1, 10); + + expect(result).toEqual({ + success: false, + message: "Not authenticated", + }); + expect(prisma.question.findMany).not.toHaveBeenCalled(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.findMany as jest.Mock).mockRejectedValue( + new Error("DB error"), + ); + + const result = await getQuestionsList(1, 10); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); + + // getQuestionById + describe("getQuestionById", () => { + it("should return question by id", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.findFirst as jest.Mock).mockResolvedValue(mockQuestion); + + const result = await getQuestionById("q-1"); + + expect(result).toEqual({ success: true, data: mockQuestion }); + expect(prisma.question.findFirst).toHaveBeenCalledWith({ + where: { id: "q-1", createdBy: mockUser.id }, + include: { tags: true }, + }); + }); + + it("should return not found when question does not exist", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await getQuestionById("nonexistent"); + + expect(result).toEqual({ + success: false, + message: "Question not found", + }); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await getQuestionById("q-1"); + + expect(result).toEqual({ + success: false, + message: "Not authenticated", + }); + expect(prisma.question.findFirst).not.toHaveBeenCalled(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.findFirst as jest.Mock).mockRejectedValue( + new Error("DB error"), + ); + + const result = await getQuestionById("q-1"); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); + + // createQuestion + describe("createQuestion", () => { + it("should create a question with tags", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.create as jest.Mock).mockResolvedValue(mockQuestion); + + const result = await createQuestion({ + question: "What is React?", + answer: "

A JS library

", + tagIds: ["tag-1"], + }); + + expect(result).toEqual({ success: true, data: mockQuestion }); + expect(prisma.question.create).toHaveBeenCalledWith({ + data: { + question: "What is React?", + answer: "

A JS library

", + createdBy: mockUser.id, + tags: { connect: [{ id: "tag-1" }] }, + }, + include: { tags: true }, + }); + }); + + it("should create a question without tags", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.create as jest.Mock).mockResolvedValue({ + ...mockQuestion, + tags: [], + }); + + const result = await createQuestion({ + question: "What is React?", + }); + + expect(result?.success).toBe(true); + expect(prisma.question.create).toHaveBeenCalledWith({ + data: { + question: "What is React?", + answer: null, + createdBy: mockUser.id, + tags: undefined, + }, + include: { tags: true }, + }); + }); + + it("should create a question with null answer", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.create as jest.Mock).mockResolvedValue(mockQuestion); + + await createQuestion({ question: "What?", answer: null }); + + expect(prisma.question.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ answer: null }), + }), + ); + }); + + it("should reject invalid data (question too short)", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + + const result = await createQuestion({ question: "a" }); + + expect(result?.success).toBe(false); + expect(prisma.question.create).not.toHaveBeenCalled(); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await createQuestion({ question: "What is React?" }); + + expect(result).toEqual({ + success: false, + message: "Not authenticated", + }); + expect(prisma.question.create).not.toHaveBeenCalled(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.create as jest.Mock).mockRejectedValue( + new Error("DB error"), + ); + + const result = await createQuestion({ question: "What is React?" }); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); + + // updateQuestion + describe("updateQuestion", () => { + it("should update a question with new tags", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.update as jest.Mock).mockResolvedValue(mockQuestion); + + const result = await updateQuestion({ + id: "q-1", + question: "Updated question?", + answer: "

Updated answer

", + tagIds: ["tag-1", "tag-2"], + }); + + expect(result).toEqual({ success: true, data: mockQuestion }); + expect(prisma.question.update).toHaveBeenCalledWith({ + where: { id: "q-1", createdBy: mockUser.id }, + data: { + question: "Updated question?", + answer: "

Updated answer

", + tags: { set: [{ id: "tag-1" }, { id: "tag-2" }] }, + }, + include: { tags: true }, + }); + }); + + it("should clear tags when tagIds is empty", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.update as jest.Mock).mockResolvedValue(mockQuestion); + + await updateQuestion({ + id: "q-1", + question: "Updated?", + tagIds: [], + }); + + expect(prisma.question.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + tags: { set: [] }, + }), + }), + ); + }); + + it("should return error when id is missing", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + + const result = await updateQuestion({ question: "No ID?" }); + + expect(result?.success).toBe(false); + expect(prisma.question.update).not.toHaveBeenCalled(); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await updateQuestion({ + id: "q-1", + question: "Updated?", + }); + + expect(result).toEqual({ + success: false, + message: "Not authenticated", + }); + expect(prisma.question.update).not.toHaveBeenCalled(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.update as jest.Mock).mockRejectedValue( + new Error("DB error"), + ); + + const result = await updateQuestion({ + id: "q-1", + question: "Updated?", + }); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); + + // deleteQuestion + describe("deleteQuestion", () => { + it("should delete a question", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.delete as jest.Mock).mockResolvedValue(mockQuestion); + + const result = await deleteQuestion("q-1"); + + expect(result).toEqual({ success: true }); + expect(prisma.question.delete).toHaveBeenCalledWith({ + where: { id: "q-1", createdBy: mockUser.id }, + }); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await deleteQuestion("q-1"); + + expect(result).toEqual({ + success: false, + message: "Not authenticated", + }); + expect(prisma.question.delete).not.toHaveBeenCalled(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.question.delete as jest.Mock).mockRejectedValue( + new Error("DB error"), + ); + + const result = await deleteQuestion("q-1"); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); + + // getTagsWithQuestionCounts + describe("getTagsWithQuestionCounts", () => { + it("should return tags with question counts", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + const mockTags = [ + { + id: "tag-1", + label: "React", + value: "react", + _count: { questions: 5 }, + }, + { + id: "tag-2", + label: "TypeScript", + value: "typescript", + _count: { questions: 3 }, + }, + ]; + (prisma.tag.findMany as jest.Mock).mockResolvedValue(mockTags); + (prisma.question.count as jest.Mock).mockResolvedValue(8); + + const result = await getTagsWithQuestionCounts(); + + expect(result).toEqual({ + success: true, + data: [ + { id: "tag-1", label: "React", value: "react", questionCount: 5 }, + { + id: "tag-2", + label: "TypeScript", + value: "typescript", + questionCount: 3, + }, + ], + totalQuestions: 8, + }); + }); + + it("should filter out tags with zero questions", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + const mockTags = [ + { + id: "tag-1", + label: "React", + value: "react", + _count: { questions: 2 }, + }, + { + id: "tag-2", + label: "Unused", + value: "unused", + _count: { questions: 0 }, + }, + ]; + (prisma.tag.findMany as jest.Mock).mockResolvedValue(mockTags); + (prisma.question.count as jest.Mock).mockResolvedValue(2); + + const result = await getTagsWithQuestionCounts(); + + expect(result?.data).toHaveLength(1); + expect(result?.data[0].label).toBe("React"); + }); + + it("should return error for unauthenticated user", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(null); + + const result = await getTagsWithQuestionCounts(); + + expect(result).toEqual({ + success: false, + message: "Not authenticated", + }); + expect(prisma.tag.findMany).not.toHaveBeenCalled(); + }); + + it("should handle database errors", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.tag.findMany as jest.Mock).mockRejectedValue( + new Error("DB error"), + ); + + const result = await getTagsWithQuestionCounts(); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); +}); From a6a3c4567fe8af7b7e09f7f2e39148a4731cba9d Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Thu, 5 Mar 2026 11:29:36 -0700 Subject: [PATCH 10/12] Add questions field column to tags table --- __tests__/tag.actions.spec.ts | 43 ++++++++++++++++++++++++-- src/actions/tag.actions.ts | 20 ++++++++---- src/components/admin/TagsContainer.tsx | 2 +- src/components/admin/TagsTable.tsx | 18 +++++++++-- src/models/job.model.ts | 1 + 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/__tests__/tag.actions.spec.ts b/__tests__/tag.actions.spec.ts index 0c792f6..75853ae 100644 --- a/__tests__/tag.actions.spec.ts +++ b/__tests__/tag.actions.spec.ts @@ -20,6 +20,9 @@ jest.mock("@prisma/client", () => { job: { count: jest.fn(), }, + question: { + count: jest.fn(), + }, }; return { PrismaClient: jest.fn(() => mPrismaClient) }; }); @@ -86,7 +89,7 @@ describe("Tag Actions", () => { describe("getTagList", () => { it("should return paginated tag list with counts", async () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); - const mockData = [{ ...mockTag, _count: { jobs: 3 } }]; + const mockData = [{ ...mockTag, _count: { jobs: 3, questions: 2 } }]; (prisma.tag.findMany as jest.Mock).mockResolvedValue(mockData); (prisma.tag.count as jest.Mock).mockResolvedValue(1); @@ -198,9 +201,10 @@ describe("Tag Actions", () => { // deleteTagById describe("deleteTagById", () => { - it("should delete a tag that has no linked jobs", async () => { + it("should delete a tag that has no linked jobs or questions", async () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); (prisma.job.count as jest.Mock).mockResolvedValue(0); + (prisma.question.count as jest.Mock).mockResolvedValue(0); (prisma.tag.delete as jest.Mock).mockResolvedValue(mockTag); const result = await deleteTagById("tag-1"); @@ -211,9 +215,10 @@ describe("Tag Actions", () => { }); }); - it("should return error when tag is linked to one or more jobs", async () => { + it("should return error when tag is linked to jobs only", async () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); (prisma.job.count as jest.Mock).mockResolvedValue(3); + (prisma.question.count as jest.Mock).mockResolvedValue(0); const result = await deleteTagById("tag-1"); @@ -225,6 +230,36 @@ describe("Tag Actions", () => { expect(prisma.tag.delete).not.toHaveBeenCalled(); }); + it("should return error when tag is linked to questions only", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.count as jest.Mock).mockResolvedValue(0); + (prisma.question.count as jest.Mock).mockResolvedValue(5); + + const result = await deleteTagById("tag-1"); + + expect(result).toEqual({ + success: false, + message: + "Skill tag cannot be deleted because it is linked to 5 question(s).", + }); + expect(prisma.tag.delete).not.toHaveBeenCalled(); + }); + + it("should return error when tag is linked to both jobs and questions", async () => { + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + (prisma.job.count as jest.Mock).mockResolvedValue(2); + (prisma.question.count as jest.Mock).mockResolvedValue(4); + + const result = await deleteTagById("tag-1"); + + expect(result).toEqual({ + success: false, + message: + "Skill tag cannot be deleted because it is linked to 2 job(s) and 4 question(s).", + }); + expect(prisma.tag.delete).not.toHaveBeenCalled(); + }); + it("should return error for unauthenticated user", async () => { (getCurrentUser as jest.Mock).mockResolvedValue(null); @@ -232,12 +267,14 @@ describe("Tag Actions", () => { expect(result).toEqual({ success: false, message: "Not authenticated" }); expect(prisma.job.count).not.toHaveBeenCalled(); + expect(prisma.question.count).not.toHaveBeenCalled(); expect(prisma.tag.delete).not.toHaveBeenCalled(); }); it("should handle database errors during deletion", async () => { (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); (prisma.job.count as jest.Mock).mockResolvedValue(0); + (prisma.question.count as jest.Mock).mockResolvedValue(0); (prisma.tag.delete as jest.Mock).mockRejectedValue(new Error("DB error")); const result = await deleteTagById("tag-1"); diff --git a/src/actions/tag.actions.ts b/src/actions/tag.actions.ts index 49acfd4..9a7fdf8 100644 --- a/src/actions/tag.actions.ts +++ b/src/actions/tag.actions.ts @@ -41,7 +41,7 @@ export const getTagList = async ( id: true, label: true, value: true, - _count: { select: { jobs: true } }, + _count: { select: { jobs: true, questions: true } }, }, orderBy: { label: "asc" }, }), @@ -91,13 +91,21 @@ export const deleteTagById = async ( throw new Error("Not authenticated"); } - const jobs = await prisma.job.count({ - where: { tags: { some: { id: tagId } } }, - }); + const [jobs, questions] = await Promise.all([ + prisma.job.count({ where: { tags: { some: { id: tagId } } } }), + prisma.question.count({ where: { tags: { some: { id: tagId } } } }), + ]); + + if (jobs > 0 || questions > 0) { + const links = [ + jobs > 0 ? `${jobs} job(s)` : "", + questions > 0 ? `${questions} question(s)` : "", + ] + .filter(Boolean) + .join(" and "); - if (jobs > 0) { throw new Error( - `Skill tag cannot be deleted because it is linked to ${jobs} job(s).`, + `Skill tag cannot be deleted because it is linked to ${links}.`, ); } diff --git a/src/components/admin/TagsContainer.tsx b/src/components/admin/TagsContainer.tsx index 2b948cf..0d24e23 100644 --- a/src/components/admin/TagsContainer.tsx +++ b/src/components/admin/TagsContainer.tsx @@ -50,7 +50,7 @@ function TagsContainer() {
- Skills + Skills/Tags
diff --git a/src/components/admin/TagsTable.tsx b/src/components/admin/TagsTable.tsx index 34bd295..c42919c 100644 --- a/src/components/admin/TagsTable.tsx +++ b/src/components/admin/TagsTable.tsx @@ -35,11 +35,21 @@ function TagsTable({ tags, reloadTags }: TagsTableProps) { }); const onDeleteTag = (tag: Tag) => { - if ((tag._count?.jobs ?? 0) > 0) { + const jobCount = tag._count?.jobs ?? 0; + const questionCount = tag._count?.questions ?? 0; + + if (jobCount > 0 || questionCount > 0) { + const links = [ + jobCount > 0 ? `${jobCount} job(s)` : "", + questionCount > 0 ? `${questionCount} question(s)` : "", + ] + .filter(Boolean) + .join(" and "); + setAlert({ openState: true, title: "Skill is in use!", - description: `This skill is linked to ${tag._count?.jobs} job(s) and cannot be deleted.`, + description: `This skill is linked to ${links} and cannot be deleted.`, deleteAction: false, }); } else { @@ -77,6 +87,7 @@ function TagsTable({ tags, reloadTags }: TagsTableProps) { Skill Label Value # Jobs + # Questions Actions @@ -90,6 +101,9 @@ function TagsTable({ tags, reloadTags }: TagsTableProps) { {tag._count?.jobs ?? 0} + + {tag._count?.questions ?? 0} + diff --git a/src/models/job.model.ts b/src/models/job.model.ts index 44350fd..22e65b4 100644 --- a/src/models/job.model.ts +++ b/src/models/job.model.ts @@ -24,6 +24,7 @@ export interface Tag { createdBy: string; _count?: { jobs: number; + questions: number; }; } From d90f8e0d6cf5b6f7b598c6ef5c028764d112318a Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Fri, 6 Mar 2026 10:05:34 -0700 Subject: [PATCH 11/12] Update readme --- README.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 88af451..b576671 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ From the project directory, run the deploy script to pull the latest changes and curl -fsSL https://raw.githubusercontent.com/Gsync/jobsync/main/deploy.sh | sudo bash -s ``` +## Contributing + +We welcome contributions! Please read our [Contributing Guidelines](./CONTRIBUTING.md) to get started. This project follows a [Code of Conduct](./CODE_OF_CONDUCT.md) — by participating, you agree to uphold its standards. + ### Credits - React diff --git a/package.json b/package.json index 2f92e42..02fbe28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jobsync", - "version": "1.1.0", + "version": "1.1.4", "private": true, "scripts": { "dev": "next dev --turbopack -p 3737", From fe7b2e8ca56a0efcfaaf89b0d0a2a0b871810341 Mon Sep 17 00:00:00 2001 From: Khuram Niaz Date: Fri, 6 Mar 2026 10:21:05 -0700 Subject: [PATCH 12/12] Update release file --- release.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/release.sh b/release.sh index 6bd2040..fc1024f 100755 --- a/release.sh +++ b/release.sh @@ -1,7 +1,7 @@ #!/bin/bash # JobSync Release & Docker Build Script -# Usage: ./release.sh [patch|minor|major] (defaults to patch) +# Usage: ./release.sh [patch|minor|major|x.y.z] (defaults to patch) set -e @@ -14,8 +14,9 @@ BUMP_TYPE="${1:-patch}" REGISTRY="ghcr.io" IMAGE_NAME="gsync/jobsync" -if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "major" ]]; then - echo -e "${RED}Error: Invalid bump type '$BUMP_TYPE'. Use patch, minor, or major.${NC}" +SEMVER_REGEX='^[0-9]+\.[0-9]+\.[0-9]+$' +if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "major" && ! "$BUMP_TYPE" =~ $SEMVER_REGEX ]]; then + echo -e "${RED}Error: Invalid argument '$BUMP_TYPE'. Use patch, minor, major, or a semver (e.g., 1.5.0).${NC}" exit 1 fi