diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f1ed53f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,77 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in the JobSync community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +**Examples of behavior that contributes to a positive environment:** + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +**Examples of unacceptable behavior:** + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers responsible for enforcement. All complaints will be reviewed and investigated promptly and fairly. + +All maintainers are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact:** Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence:** A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact:** A violation through a single incident or series of actions. + +**Consequence:** A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact:** A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence:** A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact:** Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence:** A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/inclusion). + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are available at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..267b759 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,307 @@ +# Contributing to JobSync + +Thank you for your interest in contributing to JobSync! This document outlines the process and guidelines for contributing to this project. Please read it carefully before submitting any contributions. + +## Table of Contents + +- [Contributing to JobSync](#contributing-to-jobsync) + - [Table of Contents](#table-of-contents) + - [Code of Conduct](#code-of-conduct) + - [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Local Setup](#local-setup) + - [How to Contribute](#how-to-contribute) + - [Development Workflow](#development-workflow) + - [Branch Strategy](#branch-strategy) + - [Naming Convention](#naming-convention) + - [Commit Messages](#commit-messages) + - [Pull Request Guidelines](#pull-request-guidelines) + - [Before Opening a PR](#before-opening-a-pr) + - [Keep PRs Small and Focused](#keep-prs-small-and-focused) + - [PR Checklist](#pr-checklist) + - [PR Description Template](#pr-description-template) + - [Review Process](#review-process) + - [Code Style](#code-style) + - [Testing](#testing) + - [Unit Tests (Jest)](#unit-tests-jest) + - [E2E Tests (Playwright)](#e2e-tests-playwright) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Features](#suggesting-features) + - [Questions](#questions) + +--- + +## Code of Conduct + +This project follows a [Code of Conduct](./CODE_OF_CONDUCT.md). By participating, you agree to uphold these standards. Please report unacceptable behavior to the project maintainers. + +--- + +## Getting Started + +### Prerequisites + +- Node.js 20+ +- npm +- Git + +### Local Setup + +1. **Fork** the repository on GitHub. +2. **Clone** your fork: + ```bash + git clone https://github.com//jobsync.git + cd jobsync + ``` +3. **Add the upstream remote:** + ```bash + git remote add upstream https://github.com/Gsync/jobsync.git + ``` +4. **Install dependencies:** + ```bash + npm install + ``` +5. **Copy the environment file and fill in your values:** + ```bash + cp .env.example .env + ``` +6. **Set up the database:** + ```bash + npx prisma migrate dev + ``` +7. **Start the development server:** + ```bash + npm run dev + ``` + +--- + +## How to Contribute + +There are many ways to contribute: + +- **Bug fixes** — Find something broken? Fix it and open a PR. +- **Features** — Check the [open issues](https://github.com/Gsync/jobsync/issues) for ideas, or propose a new one first. +- **Documentation** — Improve README, docs, or inline code comments. +- **Tests** — Add missing unit or e2e tests. +- **Refactoring** — Improve code quality without changing behavior. + +For significant changes or new features, **open an issue first** to discuss your approach before writing code. This avoids duplicated effort and ensures alignment with the project's direction. + +--- + +## Development Workflow + +1. **Sync your fork** with the latest `dev` branch before starting work: + ```bash + git fetch upstream + git checkout dev + git merge upstream/dev + ``` +2. **Create a focused branch** from `dev` (see [Branch Strategy](#branch-strategy)): + ```bash + git checkout -b feat/your-feature-name + ``` +3. Make your changes, keeping the scope narrow and focused. +4. **Run lint and tests** to verify everything passes (see [Testing](#testing)). +5. Commit your changes following the [commit message conventions](#commit-messages). +6. Push your branch to your fork and open a Pull Request. + +--- + +## Branch Strategy + +> **Important:** All pull requests must target the `dev` branch, **never** `main`. The `main` branch is the stable release branch and is only updated by maintainers via versioned merges from `dev`. + +### Naming Convention + +Use one of these prefixes for your branch name: + +| Prefix | Purpose | +|--------|---------| +| `feat/` | New feature | +| `fix/` | Bug fix | +| `docs/` | Documentation only | +| `refactor/` | Code refactoring (no behavior change) | +| `test/` | Adding or improving tests | +| `chore/` | Build process, tooling, dependencies | + +**Examples:** +``` +feat/add-resume-export +fix/automation-rate-limit +docs/update-api-routes +refactor/ai-provider-cleanup +test/task-actions-coverage +``` + +--- + +## Commit Messages + +Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: + +``` +(optional scope): + +[optional body] + +[optional footer] +``` + +**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + +**Examples:** +``` +feat(automations): add rate limiting for manual runs +fix(tasks): prevent deletion of tasks with linked activities +docs: update environment variable list in README +refactor(ai): extract preprocessing into dedicated module +test(job-actions): add coverage for edge cases +``` + +- Use the **imperative mood** in the summary ("add feature" not "added feature") +- Keep the summary under **72 characters** +- Reference relevant issues in the footer: `Closes #123` + +--- + +## Pull Request Guidelines + +### Before Opening a PR + +Ensure the following checks pass **locally** before submitting: + +```bash +# Lint — must produce no errors +npm run lint + +# Unit tests — all must pass +npm run test + +# (Optional) E2e tests +npm run test:e2e +``` + +A PR that fails lint or tests will **not be reviewed** until those issues are resolved. + +### Keep PRs Small and Focused + +Large PRs are difficult to review thoroughly and are more likely to introduce bugs. Follow these guidelines: + +- **One concern per PR** — a single bug fix, a single feature, or a single refactor; not all three. +- **Aim for under 400 lines changed** (excluding generated files, migrations, and lock files). +- If your change is large by necessity, break it into a sequence of smaller PRs that each build on the previous one. +- Avoid bundling unrelated changes (e.g., fixing a bug while also reformatting unrelated files). + +### PR Checklist + +Before submitting, verify: + +- [ ] Branch is based on `dev`, not `main` +- [ ] `npm run lint` passes with no errors +- [ ] `npm run test` passes with no failures +- [ ] PR is scoped to a single concern +- [ ] Changes are under ~400 lines (excluding generated/migration files) +- [ ] PR description explains **what** and **why** +- [ ] Relevant tests have been added or updated +- [ ] Documentation is updated if behavior changes +- [ ] `npx prisma generate` and a migration have been added for any schema changes + +### PR Description Template + +When opening a PR, use this structure: + +```markdown +## Summary +A brief description of what this PR does and why. + +## Changes +- List of specific changes made + +## Related Issues +Closes # + +## Testing +Describe how the change was tested. +``` + +### Review Process + +- At least one maintainer approval is required to merge. +- Address all review comments before requesting re-review. +- Keep discussion respectful and constructive — see the [Code of Conduct](./CODE_OF_CONDUCT.md). +- Maintainers may request changes, squash commits, or close a PR that no longer aligns with the project's goals. + +--- + +## Code Style + +This project uses ESLint and TypeScript strict mode. All style rules are enforced via `npm run lint`. + +Key conventions: + +- **Imports:** Use `@/` absolute imports; group by external → internal → relative. +- **Naming:** PascalCase for components, camelCase for functions/variables, kebab-case for files. +- **Types:** All types live in `src/models/*.model.ts`; Zod schemas in `src/models/*.schema.ts`. +- **Server actions:** Always validate auth with `getCurrentUser()` and return via `handleError()`. +- **Database:** Run `npx prisma generate` then `npx prisma migrate dev` after any schema change. +- **Files over 200 lines:** Break into a directory with focused modules and a barrel `index.ts`. +- **Comments:** Minimal; explain "why" not "what"; no decorative separator comments. + +--- + +## Testing + +### Unit Tests (Jest) + +```bash +npm run test # Run all unit tests +npm run test:watch # Watch mode +npm run test -- path/to/test.test.ts # Single file +npm run test -- --testNamePattern="test name" # Single test +``` + +Tests live in `__tests__/` and should be co-located where possible. Mock external dependencies (AI providers, database). Focus on server actions and component behavior. + +### E2E Tests (Playwright) + +```bash +npm run test:e2e +``` + +E2E tests live in `e2e/`. These require a running dev server. + +--- + +## Reporting Bugs + +Before filing a bug report, check if it has already been reported in the [issue tracker](https://github.com/Gsync/jobsync/issues). + +When opening a bug report, include: + +1. **Description** — clear summary of the problem. +2. **Steps to reproduce** — step-by-step instructions. +3. **Expected vs. actual behavior.** +4. **Environment** — OS, Node.js version, browser (if applicable). +5. **Screenshots or logs** — if relevant. + +--- + +## Suggesting Features + +Open a [feature request issue](https://github.com/Gsync/jobsync/issues/new) with: + +1. **Problem statement** — what problem does this solve or what need does it address? +2. **Proposed solution** — your idea for how to implement it. +3. **Alternatives considered** — other approaches you thought of. + +Feature requests are discussed before any implementation work begins. + +--- + +## Questions + +If you have a question that isn't answered here, feel free to open a [discussion](https://github.com/Gsync/jobsync/discussions) or an issue tagged `question`. + +Thank you for helping make JobSync better! 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/__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__/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__/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__/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__/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__/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/__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(); + }); + }); +}); 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" }); + }); + }); +}); diff --git a/__tests__/tag.actions.spec.ts b/__tests__/tag.actions.spec.ts new file mode 100644 index 0000000..75853ae --- /dev/null +++ b/__tests__/tag.actions.spec.ts @@ -0,0 +1,285 @@ +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(), + }, + question: { + 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, questions: 2 } }]; + (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 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"); + + 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 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"); + + 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 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); + + const result = await deleteTagById("tag-1"); + + 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"); + + expect(result).toEqual({ success: false, message: "DB error" }); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 039593e..065252b 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 c743372..dcb625f 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/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/migrations/20260305175020_add_question_model/migration.sql b/prisma/migrations/20260305175020_add_question_model/migration.sql new file mode 100644 index 0000000..7c177c9 --- /dev/null +++ b/prisma/migrations/20260305175020_add_question_model/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "Question" ( + "id" TEXT NOT NULL PRIMARY KEY, + "question" TEXT NOT NULL, + "answer" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Question_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "_QuestionToTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + CONSTRAINT "_QuestionToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Question" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_QuestionToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "Question_createdBy_idx" ON "Question"("createdBy"); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuestionToTag_AB_unique" ON "_QuestionToTag"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuestionToTag_B_index" ON "_QuestionToTag"("B"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d7436a1..bb4b44e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,9 @@ model User { Automation Automation[] Settings UserSettings? ApiKey ApiKey[] + Note Note[] + Tag Tag[] + Question Question[] } model ApiKey { @@ -282,6 +285,8 @@ model Job { Interview Interview[] Resume Resume? @relation(fields: [resumeId], references: [id]) resumeId String? + Notes Note[] + tags Tag[] // Automation discovery fields automationId String? @@ -357,26 +362,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[] @@ -386,23 +391,62 @@ 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]) } + +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]) +} + +model Tag { + id String @id @default(uuid()) + label String + value String + createdBy String + user User @relation(fields: [createdBy], references: [id]) + jobs Job[] + questions Question[] + + @@unique([value, createdBy]) +} + +model Question { + id String @id @default(uuid()) + question String + answer String? + createdBy String + user User @relation(fields: [createdBy], references: [id]) + tags Tag[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([createdBy]) +} diff --git a/release.sh b/release.sh index 7ceb227..c53769c 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 diff --git a/src/actions/job.actions.ts b/src/actions/job.actions.ts index cff295a..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(); @@ -94,6 +94,7 @@ export const getJobsList = async ( description: false, Resume: true, matchScore: true, + _count: { select: { Notes: true } }, }, orderBy: { createdAt: "desc", @@ -161,7 +162,7 @@ export async function* getJobsIterator(filter?: string, pageSize = 200) { } export const getJobDetails = async ( - jobId: string + jobId: string, ): Promise => { try { if (!jobId) { @@ -188,6 +189,7 @@ export const getJobDetails = async ( File: true, }, }, + tags: true, }, }); return { job, success: true }; @@ -198,7 +200,7 @@ export const getJobDetails = async ( }; export const createLocation = async ( - label: string + label: string, ): Promise => { try { const user = await getCurrentUser(); @@ -232,7 +234,7 @@ export const createLocation = async ( }; export const createJobSource = async ( - label: string + label: string, ): Promise => { try { const user = await getCurrentUser(); @@ -266,7 +268,7 @@ export const createJobSource = async ( }; export const addJob = async ( - data: z.infer + data: z.infer, ): Promise => { try { const user = await getCurrentUser(); @@ -289,8 +291,11 @@ export const addJob = async ( jobUrl, applied, resume, + tags, } = data; + const tagIds = tags ?? []; + const job = await prisma.job.create({ data: { jobTitleId: title, @@ -308,6 +313,9 @@ export const addJob = async ( jobUrl, applied, resumeId: resume, + ...(tagIds.length > 0 + ? { tags: { connect: tagIds.map((id) => ({ id })) } } + : {}), }, }); return { job, success: true }; @@ -318,7 +326,7 @@ export const addJob = async ( }; export const updateJob = async ( - data: z.infer + data: z.infer, ): Promise => { try { const user = await getCurrentUser(); @@ -345,8 +353,11 @@ export const updateJob = async ( jobUrl, applied, resume, + tags, } = data; + const tagIds = tags ?? []; + const job = await prisma.job.update({ where: { id, @@ -366,6 +377,7 @@ export const updateJob = async ( jobUrl, applied, resumeId: resume, + tags: { set: tagIds.map((id) => ({ id })) }, }, }); // revalidatePath("/dashboard/myjobs", "page"); @@ -378,7 +390,7 @@ export const updateJob = async ( export const updateJobStatus = async ( jobId: string, - status: JobStatus + status: JobStatus, ): Promise => { try { const user = await getCurrentUser(); @@ -421,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/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/actions/question.actions.ts b/src/actions/question.actions.ts new file mode 100644 index 0000000..c298c09 --- /dev/null +++ b/src/actions/question.actions.ts @@ -0,0 +1,184 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { AddQuestionFormSchema } from "@/models/addQuestionForm.schema"; +import { getCurrentUser } from "@/utils/user.utils"; +import { APP_CONSTANTS } from "@/lib/constants"; +import { z } from "zod"; + +export const getQuestionsList = async ( + page: number = 1, + limit: number = APP_CONSTANTS.RECORDS_PER_PAGE, + filter?: string, + search?: string +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) throw new Error("Not authenticated"); + + const offset = (page - 1) * limit; + + const whereClause: any = { + createdBy: user.id, + }; + + if (filter) { + whereClause.tags = { some: { id: filter } }; + } + + if (search) { + whereClause.OR = [ + { question: { contains: search } }, + { answer: { contains: search } }, + ]; + } + + const [data, total] = await Promise.all([ + prisma.question.findMany({ + where: whereClause, + include: { + tags: true, + }, + orderBy: [{ createdAt: "desc" }], + skip: offset, + take: limit, + }), + prisma.question.count({ where: whereClause }), + ]); + + return { success: true, data, total }; + } catch (error) { + return handleError(error, "Failed to fetch questions list."); + } +}; + +export const getQuestionById = async ( + questionId: string +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) throw new Error("Not authenticated"); + + const question = await prisma.question.findFirst({ + where: { id: questionId, createdBy: user.id }, + include: { tags: true }, + }); + + if (!question) { + return { success: false, message: "Question not found" }; + } + + return { success: true, data: question }; + } catch (error) { + return handleError(error, "Failed to fetch question."); + } +}; + +export const createQuestion = async ( + data: z.infer +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) throw new Error("Not authenticated"); + + const validatedData = AddQuestionFormSchema.parse(data); + + const question = await prisma.question.create({ + data: { + question: validatedData.question, + answer: validatedData.answer || null, + createdBy: user.id, + tags: validatedData.tagIds?.length + ? { connect: validatedData.tagIds.map((id) => ({ id })) } + : undefined, + }, + include: { tags: true }, + }); + + return { success: true, data: question }; + } catch (error) { + return handleError(error, "Failed to create question."); + } +}; + +export const updateQuestion = async ( + data: z.infer +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) throw new Error("Not authenticated"); + + if (!data.id) throw new Error("Question ID is required for update"); + + const validatedData = AddQuestionFormSchema.parse(data); + + const question = await prisma.question.update({ + where: { id: data.id, createdBy: user.id }, + data: { + question: validatedData.question, + answer: validatedData.answer || null, + tags: { + set: validatedData.tagIds?.map((id) => ({ id })) || [], + }, + }, + include: { tags: true }, + }); + + return { success: true, data: question }; + } catch (error) { + return handleError(error, "Failed to update question."); + } +}; + +export const deleteQuestion = async ( + questionId: string +): Promise => { + try { + const user = await getCurrentUser(); + if (!user) throw new Error("Not authenticated"); + + await prisma.question.delete({ + where: { id: questionId, createdBy: user.id }, + }); + + return { success: true }; + } catch (error) { + return handleError(error, "Failed to delete question."); + } +}; + +export const getTagsWithQuestionCounts = async (): Promise< + any | undefined +> => { + try { + const user = await getCurrentUser(); + if (!user) throw new Error("Not authenticated"); + + const tags = await prisma.tag.findMany({ + where: { createdBy: user.id }, + include: { + _count: { + select: { questions: true }, + }, + }, + orderBy: { label: "asc" }, + }); + + const data = tags + .filter((tag) => tag._count.questions > 0) + .map((tag) => ({ + id: tag.id, + label: tag.label, + value: tag.value, + questionCount: tag._count.questions, + })); + + const totalQuestions = await prisma.question.count({ + where: { createdBy: user.id }, + }); + + return { success: true, data, totalQuestions }; + } catch (error) { + return handleError(error, "Failed to fetch tags with question counts."); + } +}; diff --git a/src/actions/tag.actions.ts b/src/actions/tag.actions.ts new file mode 100644 index 0000000..9a7fdf8 --- /dev/null +++ b/src/actions/tag.actions.ts @@ -0,0 +1,121 @@ +"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, questions: 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, 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 "); + + throw new Error( + `Skill tag cannot be deleted because it is linked to ${links}.`, + ); + } + + 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/app/dashboard/questions/QuestionsPageClient.tsx b/src/app/dashboard/questions/QuestionsPageClient.tsx new file mode 100644 index 0000000..1ffa955 --- /dev/null +++ b/src/app/dashboard/questions/QuestionsPageClient.tsx @@ -0,0 +1,62 @@ +"use client"; +import QuestionsContainer from "@/components/questions/QuestionsContainer"; +import QuestionsSidebar from "@/components/questions/QuestionsSidebar"; +import { Tag } from "@/models/job.model"; +import { useState, useCallback } from "react"; +import { getTagsWithQuestionCounts } from "@/actions/question.actions"; + +type TagWithCount = { + id: string; + label: string; + value: string; + questionCount: number; +}; + +type QuestionsPageClientProps = { + allTags: Tag[]; + tagsWithCounts: TagWithCount[]; + totalQuestions: number; +}; + +function QuestionsPageClient({ + allTags, + tagsWithCounts, + totalQuestions, +}: QuestionsPageClientProps) { + const [filterKey, setFilterKey] = useState(undefined); + const [sidebarCounts, setSidebarCounts] = + useState(tagsWithCounts); + const [sidebarTotal, setSidebarTotal] = useState(totalQuestions); + + const onFilterChange = (filter: string | undefined) => { + setFilterKey(filter); + }; + + const refreshSidebarCounts = useCallback(async () => { + const result = await getTagsWithQuestionCounts(); + if (result?.success) { + setSidebarCounts(result.data); + setSidebarTotal(result.totalQuestions); + } + }, []); + + return ( +
+ +
+ +
+
+ ); +} + +export default QuestionsPageClient; diff --git a/src/app/dashboard/questions/page.tsx b/src/app/dashboard/questions/page.tsx new file mode 100644 index 0000000..cbaa0e3 --- /dev/null +++ b/src/app/dashboard/questions/page.tsx @@ -0,0 +1,21 @@ +import QuestionsPageClient from "./QuestionsPageClient"; +import { getTagsWithQuestionCounts } from "@/actions/question.actions"; +import { getAllTags } from "@/actions/tag.actions"; +import React from "react"; + +async function Questions() { + const [allTags, tagsWithCounts] = await Promise.all([ + getAllTags(), + getTagsWithQuestionCounts(), + ]); + + return ( + + ); +} + +export default Questions; 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..0d24e23 --- /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/Tags +
+
+ +
+
+
+ + {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..c42919c --- /dev/null +++ b/src/components/admin/TagsTable.tsx @@ -0,0 +1,144 @@ +"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) => { + 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 ${links} 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 + # Questions + Actions + + + + {tags.map((tag: Tag) => ( + + {tag.label} + + {tag.value} + + + {tag._count?.jobs ?? 0} + + + {tag._count?.questions ?? 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/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) => (
+ {/* Add Skill Tags */} +
+ ( + + Add Skill + + field.onChange(ids)} + /> + + + + )} + /> +
+ {/* Job Description */}
+ {editJob && }
@@ -95,7 +96,7 @@ function JobDetails({ job }: { job: JobResponse }) { className={cn( "w-[70px] justify-center", job.Status?.value === "applied" && "bg-cyan-500", - job.Status?.value === "interview" && "bg-green-500" + job.Status?.value === "interview" && "bg-green-500", )} > {job.Status?.label} @@ -105,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: @@ -120,6 +130,7 @@ function JobDetails({ job }: { job: JobResponse }) {
+ {parsedMatchData && (

diff --git a/src/components/myjobs/JobsContainer.tsx b/src/components/myjobs/JobsContainer.tsx index 55875d3..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, @@ -40,6 +41,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"; @@ -50,6 +52,7 @@ type MyJobsProps = { titles: JobTitle[]; locations: JobLocation[]; sources: JobSource[]; + tags: Tag[]; }; function JobsContainer({ @@ -58,6 +61,7 @@ function JobsContainer({ titles, locations, sources, + tags, }: MyJobsProps) { const router = useRouter(); const pathname = usePathname(); @@ -69,7 +73,7 @@ function JobsContainer({ return params.toString(); }, - [queryParams] + [queryParams], ); const [jobs, setJobs] = useState([]); const [page, setPage] = useState(1); @@ -81,6 +85,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; @@ -92,7 +98,7 @@ function JobsContainer({ page, jobsPerPage, filter, - search + search, ); if (success && data) { setJobs((prev) => (page === 1 ? data : [...prev, ...data])); @@ -109,7 +115,7 @@ function JobsContainer({ return; } }, - [jobsPerPage] + [jobsPerPage], ); const reloadJobs = useCallback(async () => { @@ -171,6 +177,11 @@ function JobsContainer({ setEditJob(null); }; + const onAddNote = (jobId: string) => { + setNoteJobId(jobId); + setNoteDialogOpen(true); + }; + useEffect(() => { (async () => await loadJobs(1))(); }, [loadJobs]); @@ -284,6 +295,7 @@ function JobsContainer({ jobTitles={titles} locations={locations} jobSources={sources} + tags={tags} editJob={editJob} resetEditJob={resetEditJob} /> @@ -299,6 +311,7 @@ function JobsContainer({ deleteJob={onDeleteJob} editJob={onEditJob} onChangeJobStatus={onChangeJobStatus} + onAddNote={onAddNote} />
loadJobs(page + 1, filterKey, searchTerm || undefined)} + onClick={() => + loadJobs(page + 1, filterKey, searchTerm || undefined) + } disabled={loading} className="btn btn-primary" > @@ -331,6 +346,12 @@ function JobsContainer({ + 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..3f938c4 --- /dev/null +++ b/src/components/myjobs/NotesCollapsibleSection.tsx @@ -0,0 +1,245 @@ +"use client"; +import { useCallback, useEffect, useState, useTransition } from "react"; +import { NoteResponse } from "@/models/note.model"; +import { + getNotesByJobId, + deleteNote, + addNote, + updateNote, +} from "@/actions/note.actions"; +import { NoteCard } from "./NoteCard"; +import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; +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) { + const [notes, setNotes] = useState([]); + const [isOpen, setIsOpen] = useState(false); + 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); + if (result.success) { + setNotes(result.data); + } + }, [jobId]); + + useEffect(() => { + loadNotes(); + }, [loadNotes]); + + const handleAddNote = () => { + setEditingNote(null); + setEditorContent(""); + setIsAdding(true); + setIsOpen(true); + }; + + const handleEdit = (note: NoteResponse) => { + setIsAdding(false); + setEditingNote(note); + setEditorContent(note.content); + }; + + const handleCancel = () => { + setIsAdding(false); + setEditingNote(null); + setEditorContent(""); + }; + + 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 handleDeleteClick = (noteId: string) => { + setDeleteConfirmId(noteId); + }; + + 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} + + )} + + + +
+ + {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 new file mode 100644 index 0000000..5d416da --- /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/myjobs/TagInput.tsx b/src/components/myjobs/TagInput.tsx new file mode 100644 index 0000000..18496b7 --- /dev/null +++ b/src/components/myjobs/TagInput.tsx @@ -0,0 +1,181 @@ +"use client"; +import { useState, useTransition } from "react"; +import { X, ChevronsUpDown, CirclePlus, Loader } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tag } from "@/models/job.model"; +import { createTag } from "@/actions/tag.actions"; +import { toast } from "../ui/use-toast"; +import { cn } from "@/lib/utils"; + +const MAX_TAGS = 10; + +interface TagInputProps { + availableTags: Tag[]; + selectedTagIds: string[]; + onChange: (ids: string[]) => void; +} + +export function TagInput({ + availableTags, + selectedTagIds, + onChange, +}: TagInputProps) { + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + const [localTags, setLocalTags] = useState(availableTags); + const [isPending, startTransition] = useTransition(); + + const selectedTags = localTags.filter((t) => selectedTagIds.includes(t.id)); + const isMaxReached = selectedTagIds.length >= MAX_TAGS; + + // Tags not yet selected, filtered by input + const filteredOptions = localTags.filter( + (t) => + !selectedTagIds.includes(t.id) && + t.label.toLowerCase().includes(inputValue.toLowerCase()), + ); + + // Whether the typed value exactly matches an existing tag (case-insensitive) + const exactMatchExists = localTags.some( + (t) => t.value === inputValue.trim().toLowerCase(), + ); + + const addTagById = (id: string) => { + if (selectedTagIds.length >= MAX_TAGS) return; + onChange([...selectedTagIds, id]); + }; + + const removeTagById = (id: string) => { + onChange(selectedTagIds.filter((tid) => tid !== id)); + }; + + const handleCreate = () => { + const label = inputValue.trim(); + if (!label || isMaxReached) return; + + startTransition(async () => { + const result = await createTag(label); + if (!result?.success) { + toast({ + variant: "destructive", + title: "Error!", + description: result?.message ?? "Failed to create skill tag.", + }); + return; + } + const newTag: Tag = result.data; + // Add to local pool if not already there + setLocalTags((prev) => + prev.some((t) => t.id === newTag.id) ? prev : [...prev, newTag], + ); + addTagById(newTag.id); + setInputValue(""); + setOpen(false); + }); + }; + + const handleSelect = (tagId: string) => { + addTagById(tagId); + setInputValue(""); + setOpen(false); + }; + + return ( +
+ + + + + + + + + {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/components/questions/QuestionCard.tsx b/src/components/questions/QuestionCard.tsx new file mode 100644 index 0000000..2fdfebf --- /dev/null +++ b/src/components/questions/QuestionCard.tsx @@ -0,0 +1,146 @@ +"use client"; +import { useState } from "react"; +import { Question } from "@/models/question.model"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +type QuestionCardProps = { + question: Question; + onEdit: (question: Question) => void; + onDelete: (questionId: string) => void; +}; + +function truncateHtml(html: string, maxLength: number): string { + if (html.length <= maxLength) return html; + return html.substring(0, maxLength) + "..."; +} + +export function QuestionCard({ + question, + onEdit, + onDelete, +}: QuestionCardProps) { + const [expanded, setExpanded] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const hasAnswer = question.answer && question.answer.trim().length > 0; + const isLongAnswer = hasAnswer && question.answer!.length > 300; + + return ( + <> +
setExpanded(!expanded)} + > +
+

{question.question}

+ + + + + + { + 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/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/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/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/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/job.model.ts b/src/models/job.model.ts index 0b0e58b..22e65b4 100644 --- a/src/models/job.model.ts +++ b/src/models/job.model.ts @@ -17,6 +17,17 @@ export interface JobForm { applied: boolean; } +export interface Tag { + id: string; + label: string; + value: string; + createdBy: string; + _count?: { + jobs: number; + questions: number; + }; +} + export interface JobResponse { id: string; userId: string; @@ -37,6 +48,8 @@ export interface JobResponse { Resume?: Resume; matchScore?: number | null; matchData?: string | null; + tags?: Tag[]; + _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." }), +}); 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; +}