diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d0e29b8..dbf255c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,8 @@ "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", + "lru-cache": "^11.2.2", + "mongodb": "^6.14.2", "next": "15.1.7", "pino": "^9.11.0", @@ -68,6 +70,12 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/@babel/runtime": { "version": "7.26.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", @@ -2585,6 +2593,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.23.9", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", @@ -4576,10 +4596,13 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -5154,6 +5177,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8f22912..13f22ab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", + "lru-cache": "^11.2.2", "mongodb": "^6.14.2", "next": "15.1.7", "pino": "^9.11.0", diff --git a/frontend/src/actions/feedback.actions.ts b/frontend/src/actions/feedback.actions.ts new file mode 100644 index 0000000..49f2d2d --- /dev/null +++ b/frontend/src/actions/feedback.actions.ts @@ -0,0 +1,92 @@ +"use server"; + +import logger from "@/lib/logger"; + +// Type for our form data +export interface FeedbackFormData { + email?: string; + message: string; +} + +export async function submitFeedback(data: FeedbackFormData) { + const databaseId = process.env.NOTION_DATABASE_ID; + const notionApiKey = process.env.NOTION_API_KEY; + + if (!databaseId) { + logger.error("NOTION_DATABASE_ID environment variable is not set"); + throw new Error("NOTION_DATABASE_ID environment variable is not set"); + } + + if (!notionApiKey) { + logger.error("NOTION_API_KEY environment variable is not set"); + throw new Error("NOTION_API_KEY environment variable is not set"); + } + + try { + const response = await fetch("https://api.notion.com/v1/pages", { + method: "POST", + headers: { + Authorization: `Bearer ${notionApiKey}`, + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + }, + body: JSON.stringify({ + parent: { + database_id: databaseId, + }, + properties: { + Email: { + title: [ + { + text: { + content: data.email || "Anonymous", + }, + }, + ], + }, + Feedback: { + rich_text: [ + { + text: { + content: data.message, + }, + }, + ], + }, + Date: { + date: { + start: new Date().toISOString(), + }, + }, + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + logger.error( + { error: errorData, status: response.status }, + "Notion API error during feedback submission", + ); + return { + success: false, + message: `We're sorry, but there was an issue submitting your feedback. Please try again later.`, + }; + } + + logger.info( + { email: data.email || "Anonymous" }, + "Feedback submitted successfully", + ); + return { + success: true, + message: "Thank you! Your feedback has been submitted successfully.", + }; + } catch (error) { + logger.error(error, "Unexpected error during feedback submission"); + return { + success: false, + message: `An unexpected error occurred while submitting your feedback. Please try again.`, + }; + } +} diff --git a/frontend/src/actions/jobs.fetch.ts b/frontend/src/actions/jobs.fetch.ts new file mode 100644 index 0000000..7ac1f23 --- /dev/null +++ b/frontend/src/actions/jobs.fetch.ts @@ -0,0 +1,284 @@ +import { MongoClient, ObjectId } from "mongodb"; +import { JobFilters } from "@/types/filters"; +import { Job } from "@/types/job"; +import serializeJob from "@/lib/utils"; +import logger from "@/lib/logger"; +import { LRUCache } from "lru-cache"; + +const PAGE_SIZE = 20; + +// Shared cache for job results +type CacheValue = { jobs: Job[]; total: number } | Job; + +const jobCache = new LRUCache({ + max: 500, + ttl: 1000 * 3600, // 1 hour + allowStale: false, +}); + +// Helper to normalize filters for cache key (handles string vs array for array fields) +function normalizeFiltersForKey( + filters: Partial, +): Record { + const arrayFields = [ + "workingRights[]", + "locations[]", + "industryFields[]", + "jobTypes[]", + ]; + const normalized: Record = { + search: (filters.search || "").toLowerCase().trim(), + page: (filters.page || 1).toString(), + }; + + arrayFields.forEach((field) => { + const val = filters[field as keyof Partial]; + if (val !== undefined) { + const arr = Array.isArray(val) + ? val + : typeof val === "string" + ? [val] + : []; + normalized[field] = arr.sort().join(","); + } + }); + + // Include other scalar fields if present + if (filters.jobTypes) + normalized.jobTypes = (filters.jobTypes as string[]).sort().join(","); + if (filters.locations) + normalized.locations = (filters.locations as string[]).sort().join(","); + // Add similar for others if needed, but since searchParams uses [] keys, prioritize those + + return normalized; +} + +/** + * Helper function to build a query object from filters. + * @param filters - The job filters from the client. + * @param additional - Additional query overrides (e.g. { is_sponsor: true }). + * @returns The query object to use with MongoDB. + */ +function buildJobQuery( + filters: Partial, + additional?: Record, +) { + const array_jobs = JSON.parse(JSON.stringify(filters, null, 2)); + const query = { + outdated: false, + ...(array_jobs["workingRights[]"] !== undefined && + array_jobs["workingRights[]"].length && { + working_rights: { + $in: Array.isArray(array_jobs["workingRights[]"]) + ? array_jobs["workingRights[]"] + : [array_jobs["workingRights[]"]], + }, + }), + ...(array_jobs["locations[]"] !== undefined && + array_jobs["locations[]"].length && { + locations: { + $in: Array.isArray(array_jobs["locations[]"]) + ? array_jobs["locations[]"] + : [array_jobs["locations[]"]], + }, + }), + ...(array_jobs["industryFields[]"] !== undefined && + array_jobs["industryFields[]"].length && { + industry_field: { + $in: Array.isArray(array_jobs["industryFields[]"]) + ? array_jobs["industryFields[]"] + : [array_jobs["industryFields[]"]], + }, + }), + ...(array_jobs["jobTypes[]"] !== undefined && + array_jobs["jobTypes[]"].length && { + type: { + $in: Array.isArray(array_jobs["jobTypes[]"]) + ? array_jobs["jobTypes[]"] + : [array_jobs["jobTypes[]"]], + }, + }), + ...(filters.search && { + $or: [ + { title: { $regex: filters.search, $options: "i" } }, + { "company.name": { $regex: filters.search, $options: "i" } }, + ], + }), + ...additional, + }; + return query; +} + +/** + * Helper function to manage a MongoDB connection. + * @param callback - The function that uses the connected MongoClient. + * @returns The result from the callback. + */ +async function withDbConnection( + callback: (client: MongoClient) => Promise, +): Promise { + if (!process.env.MONGODB_URI) { + logger.error("MONGODB_URI environment variable is not set"); + throw new Error( + "MongoDB URI is not configured. Please check environment variables.", + ); + } + const client = new MongoClient(process.env.MONGODB_URI); + try { + await client.connect(); + logger.debug("MongoDB connected successfully"); + return await callback(client); + } catch (error) { + logger.error(error, "Failed to connect to MongoDB or execute callback"); + throw error; + } finally { + await client.close(); + } +} + +/** + * Fetches paginated and filtered job listings from MongoDB. + */ +export async function getJobs( + filters: Partial, + minSponsors: number = -1, + prioritySponsors: Array = ["IMC", "Atlassian"], +): Promise<{ jobs: Job[]; total: number }> { + const page = filters.page || 1; + const normalizedFilters = normalizeFiltersForKey(filters); + const priorityStr = prioritySponsors.sort().join(","); + const cacheKey = `jobs:${JSON.stringify(normalizedFilters)}:${page}:${minSponsors}:${priorityStr}`; + + // Check cache first + const cached = jobCache.get(cacheKey); + if (cached) { + logger.debug({ cacheKey }, "Returning cached jobs"); + return cached as { jobs: Job[]; total: number }; + } + + logger.info( + { filters, minSponsors, prioritySponsors }, + "Fetching jobs with filters", + ); + + return await withDbConnection(async (client) => { + const collection = client.db("default").collection("active_jobs"); + const query = buildJobQuery(filters); + minSponsors = minSponsors === -1 ? (page == 1 ? 3 : 0) : minSponsors; + + try { + if (minSponsors == 0) { + const [jobs, total] = await Promise.all([ + collection + .find(query) + .sort({ created_at: -1 }) + .skip((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + .toArray(), + collection.countDocuments(query), + ]); + logger.debug({ total }, "Fetched non-sponsored jobs"); + const result = { + jobs: (jobs as MongoJob[]) + .map(serializeJob) + .map((job) => ({ ...job, highlight: false })), + total, + }; + jobCache.set(cacheKey, result); + return result; + } else { + const sponsoredQuery = { ...query, is_sponsored: true }; + + let sponsoredJobs = await collection + .aggregate([ + { $match: sponsoredQuery }, + { $sample: { size: minSponsors * 8 } }, + ]) + .toArray(); + + sponsoredJobs = sponsoredJobs + .filter((job) => { + const isPriority = prioritySponsors.includes(job.company.name); + return isPriority ? Math.random() < 0.65 : Math.random() >= 0.35; + }) + .slice(0, minSponsors) + .map((job) => ({ ...job, highlight: true })); + + const sponsoredJobIds = sponsoredJobs.map((job) => job._id); + + const filteredQuery = { ...query, _id: { $nin: sponsoredJobIds } }; + + const [otherJobs, total] = await Promise.all([ + collection + .find(filteredQuery) + .sort({ created_at: -1 }) + .skip((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE - sponsoredJobs.length) + .toArray(), + collection.countDocuments(query), + ]); + + const mergedJobs = [ + ...sponsoredJobs.map((job) => ({ ...job, highlight: true })), + ...otherJobs.map((job) => ({ ...job, highlight: false })), + ].slice(0, PAGE_SIZE); + + logger.debug( + { + sponsoredCount: sponsoredJobs.length, + otherCount: otherJobs.length, + total, + }, + "Fetched sponsored and other jobs", + ); + const result = { + jobs: (mergedJobs as MongoJob[]).map(serializeJob), + total, + }; + jobCache.set(cacheKey, result); + return result; + } + } catch (error) { + logger.error({ query, filters }, "Error fetching jobs"); + throw error; + } + }); +} + +/** + * Fetches a single job by its id. + */ +export async function getJobById(id: string): Promise { + const cacheKey = `job:${id}`; + + // Check cache first + const cached = jobCache.get(cacheKey); + if (cached) { + logger.debug({ id }, "Returning cached job"); + return cached as Job; + } + + logger.info({ id }, "Fetching job by ID"); + + return await withDbConnection(async (client) => { + const collection = client.db("default").collection("active_jobs"); + const job = await collection.findOne({ + _id: new ObjectId(id), + outdated: false, + }); + if (!job) { + logger.warn({ id }, "Job not found"); + return null; + } + logger.debug({ id }, "Job fetched successfully"); + const serializedJob = serializeJob(job as MongoJob); + jobCache.set(cacheKey, serializedJob); + return serializedJob; + }); +} + +// Define the MongoJob interface with the correct DB field names. +export interface MongoJob extends Omit { + _id: ObjectId; + is_sponsored: boolean; +} diff --git a/frontend/src/app/jobs/[id]/page.tsx b/frontend/src/app/jobs/[id]/page.tsx index 187a5a6..ef232d9 100644 --- a/frontend/src/app/jobs/[id]/page.tsx +++ b/frontend/src/app/jobs/[id]/page.tsx @@ -1,6 +1,6 @@ // src/app/jobs/[id]/page.tsx -import { getJobById } from "@/app/jobs/actions"; +import { getJobById } from "@/actions/jobs.fetch"; import { notFound } from "next/navigation"; import { Job } from "@/types/job"; import JobDetailsWrapper from "@/components/jobs/job-details-wrapper"; diff --git a/frontend/src/app/jobs/page.tsx b/frontend/src/app/jobs/page.tsx index 99c5f88..b89af99 100644 --- a/frontend/src/app/jobs/page.tsx +++ b/frontend/src/app/jobs/page.tsx @@ -3,7 +3,7 @@ import FilterSection from "@/components/filters/filter-section"; import JobList from "@/components/jobs/job-list"; import JobDetails from "@/components/jobs/job-details"; import { JobFilters } from "@/types/filters"; -import { getJobs } from "@/app/jobs/actions"; +import { getJobs } from "@/actions/jobs.fetch"; import NoResults from "@/components/ui/no-results"; import { Suspense } from "react"; import JobListLoading from "@/components/layout/job-list-loading"; @@ -13,6 +13,8 @@ export const metadata = { title: "Jobs", }; +export const revalidate = 3600; // 1 hour cache + export default async function JobsPage({ searchParams, }: { diff --git a/frontend/src/components/ui/feedback-button.tsx b/frontend/src/components/ui/feedback-button.tsx index eac1b9b..101dacd 100644 --- a/frontend/src/components/ui/feedback-button.tsx +++ b/frontend/src/components/ui/feedback-button.tsx @@ -12,7 +12,7 @@ import { } from "@mantine/core"; import { IconMessageCircle } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; -import { submitFeedback } from "@/app/actions"; +import { submitFeedback } from "@/actions/feedback.actions"; export default function FeedbackButton() { const [opened, setOpened] = useState(false); diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0dfcf6b..c598b5b 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,6 @@ import { FilterState } from "@/types/filters"; import { Job, WORKING_RIGHTS, WorkingRight } from "@/types/job"; -import { MongoJob } from "@/app/jobs/actions"; +import { MongoJob } from "@/actions/jobs.fetch"; /** * Creates a URL query string from a partial FilterState object.