|
| 1 | +// /src/app/jobs/actions.ts |
| 2 | +"use server"; |
| 3 | + |
| 4 | +import { MongoClient, ObjectId } from "mongodb"; |
| 5 | +import { JobFilters } from "@/types/filters"; |
| 6 | +import { Job } from "@/types/job"; |
| 7 | +import serializeJob from "@/lib/utils"; |
| 8 | +import logger from "@/lib/logger"; |
| 9 | + |
| 10 | +const PAGE_SIZE = 20; |
| 11 | + |
| 12 | +// Define the MongoJob interface with the correct DB field names. |
| 13 | +export interface MongoJob extends Omit<Job, "id"> { |
| 14 | + _id: ObjectId; |
| 15 | + is_sponsored: boolean; |
| 16 | +} |
| 17 | + |
| 18 | +/** |
| 19 | + * Helper function to build a query object from filters. |
| 20 | + * @param filters - The job filters from the client. |
| 21 | + * @param additional - Additional query overrides (e.g. { is_sponsor: true }). |
| 22 | + * @returns The query object to use with MongoDB. |
| 23 | + */ |
| 24 | +function buildJobQuery( |
| 25 | + filters: Partial<JobFilters>, |
| 26 | + additional?: Record<string, unknown>, |
| 27 | +) { |
| 28 | + const array_jobs = JSON.parse(JSON.stringify(filters, null, 2)); |
| 29 | + const query = { |
| 30 | + outdated: false, |
| 31 | + ...(array_jobs["workingRights[]"] !== undefined && |
| 32 | + array_jobs["workingRights[]"].length && { |
| 33 | + working_rights: { |
| 34 | + $in: Array.isArray(array_jobs["workingRights[]"]) |
| 35 | + ? array_jobs["workingRights[]"] |
| 36 | + : [array_jobs["workingRights[]"]], |
| 37 | + }, |
| 38 | + }), |
| 39 | + ...(array_jobs["locations[]"] !== undefined && |
| 40 | + array_jobs["locations[]"].length && { |
| 41 | + locations: { |
| 42 | + $in: Array.isArray(array_jobs["locations[]"]) |
| 43 | + ? array_jobs["locations[]"] |
| 44 | + : [array_jobs["locations[]"]], |
| 45 | + }, |
| 46 | + }), |
| 47 | + ...(array_jobs["industryFields[]"] !== undefined && |
| 48 | + array_jobs["industryFields[]"].length && { |
| 49 | + industry_field: { |
| 50 | + $in: Array.isArray(array_jobs["industryFields[]"]) |
| 51 | + ? array_jobs["industryFields[]"] |
| 52 | + : [array_jobs["industryFields[]"]], |
| 53 | + }, |
| 54 | + }), |
| 55 | + ...(array_jobs["jobTypes[]"] !== undefined && |
| 56 | + array_jobs["jobTypes[]"].length && { |
| 57 | + type: { |
| 58 | + $in: Array.isArray(array_jobs["jobTypes[]"]) |
| 59 | + ? array_jobs["jobTypes[]"] |
| 60 | + : [array_jobs["jobTypes[]"]], |
| 61 | + }, |
| 62 | + }), |
| 63 | + ...(filters.search && { |
| 64 | + $or: [ |
| 65 | + { title: { $regex: filters.search, $options: "i" } }, |
| 66 | + { "company.name": { $regex: filters.search, $options: "i" } }, |
| 67 | + ], |
| 68 | + }), |
| 69 | + ...additional, |
| 70 | + }; |
| 71 | + return query; |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Helper function to manage a MongoDB connection. |
| 76 | + * @param callback - The function that uses the connected MongoClient. |
| 77 | + * @returns The result from the callback. |
| 78 | + */ |
| 79 | +async function withDbConnection<T>( |
| 80 | + callback: (client: MongoClient) => Promise<T>, |
| 81 | +): Promise<T> { |
| 82 | + if (!process.env.MONGODB_URI) { |
| 83 | + logger.error("MONGODB_URI environment variable is not set"); |
| 84 | + throw new Error( |
| 85 | + "MongoDB URI is not configured. Please check environment variables.", |
| 86 | + ); |
| 87 | + } |
| 88 | + const client = new MongoClient(process.env.MONGODB_URI); |
| 89 | + try { |
| 90 | + await client.connect(); |
| 91 | + logger.debug("MongoDB connected successfully"); |
| 92 | + return await callback(client); |
| 93 | + } catch (error) { |
| 94 | + logger.error(error, "Failed to connect to MongoDB or execute callback"); |
| 95 | + throw error; |
| 96 | + } finally { |
| 97 | + await client.close(); |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +/** |
| 102 | + * Fetches paginated and filtered job listings from MongoDB. |
| 103 | + */ |
| 104 | +export async function getJobs( |
| 105 | + filters: Partial<JobFilters>, |
| 106 | + minSponsors: number = -1, |
| 107 | + prioritySponsors: Array<string> = ["IMC", "Atlassian"], |
| 108 | +): Promise<{ jobs: Job[]; total: number }> { |
| 109 | + logger.info( |
| 110 | + { filters, minSponsors, prioritySponsors }, |
| 111 | + "Fetching jobs with filters", |
| 112 | + ); |
| 113 | + return await withDbConnection(async (client) => { |
| 114 | + const collection = client.db("default").collection("active_jobs"); |
| 115 | + const query = buildJobQuery(filters); |
| 116 | + const page = filters.page || 1; |
| 117 | + const skip = (page - 1) * PAGE_SIZE; |
| 118 | + minSponsors = minSponsors === -1 ? (page == 1 ? 3 : 0) : minSponsors; |
| 119 | + |
| 120 | + try { |
| 121 | + if (minSponsors == 0) { |
| 122 | + const [jobs, total] = await Promise.all([ |
| 123 | + collection |
| 124 | + .find(query) |
| 125 | + .sort({ created_at: -1 }) |
| 126 | + .skip(skip) |
| 127 | + .limit(PAGE_SIZE) |
| 128 | + .toArray(), |
| 129 | + collection.countDocuments(query), |
| 130 | + ]); |
| 131 | + logger.debug({ total }, "Fetched non-sponsored jobs"); |
| 132 | + return { |
| 133 | + jobs: (jobs as MongoJob[]) |
| 134 | + .map(serializeJob) |
| 135 | + .map((job) => ({ ...job, highlight: false })), |
| 136 | + total, |
| 137 | + }; |
| 138 | + } else { |
| 139 | + const sponsoredQuery = { ...query, is_sponsored: true }; |
| 140 | + |
| 141 | + let sponsoredJobs = await collection |
| 142 | + .aggregate([ |
| 143 | + { $match: sponsoredQuery }, |
| 144 | + { $sample: { size: minSponsors * 8 } }, |
| 145 | + ]) |
| 146 | + .toArray(); |
| 147 | + |
| 148 | + sponsoredJobs = sponsoredJobs |
| 149 | + .filter((job) => { |
| 150 | + const isPriority = prioritySponsors.includes(job.company.name); |
| 151 | + return isPriority ? Math.random() < 0.65 : Math.random() >= 0.35; |
| 152 | + }) |
| 153 | + .slice(0, minSponsors) |
| 154 | + .map((job) => ({ ...job, highlight: true })); |
| 155 | + |
| 156 | + const sponsoredJobIds = sponsoredJobs.map((job) => job._id); |
| 157 | + |
| 158 | + const filteredQuery = { ...query, _id: { $nin: sponsoredJobIds } }; |
| 159 | + |
| 160 | + const [otherJobs, total] = await Promise.all([ |
| 161 | + collection |
| 162 | + .find(filteredQuery) |
| 163 | + .sort({ created_at: -1 }) |
| 164 | + .skip(skip) |
| 165 | + .limit(PAGE_SIZE - sponsoredJobs.length) |
| 166 | + .toArray(), |
| 167 | + collection.countDocuments(query), |
| 168 | + ]); |
| 169 | + |
| 170 | + const mergedJobs = [ |
| 171 | + ...sponsoredJobs.map((job) => ({ ...job, highlight: true })), |
| 172 | + ...otherJobs.map((job) => ({ ...job, highlight: false })), |
| 173 | + ].slice(0, PAGE_SIZE); |
| 174 | + |
| 175 | + logger.debug( |
| 176 | + { |
| 177 | + sponsoredCount: sponsoredJobs.length, |
| 178 | + otherCount: otherJobs.length, |
| 179 | + total, |
| 180 | + }, |
| 181 | + "Fetched sponsored and other jobs", |
| 182 | + ); |
| 183 | + return { |
| 184 | + jobs: (mergedJobs as MongoJob[]).map(serializeJob), |
| 185 | + total, |
| 186 | + }; |
| 187 | + } |
| 188 | + } catch (error) { |
| 189 | + logger.error({ query, filters }, "Error fetching jobs"); |
| 190 | + throw error; |
| 191 | + } |
| 192 | + }); |
| 193 | +} |
| 194 | + |
| 195 | +/** |
| 196 | + * Fetches a single job by its id. |
| 197 | + */ |
| 198 | +export async function getJobById(id: string): Promise<Job | null> { |
| 199 | + logger.info({ id }, "Fetching job by ID"); |
| 200 | + return await withDbConnection(async (client) => { |
| 201 | + const collection = client.db("default").collection("active_jobs"); |
| 202 | + const job = await collection.findOne({ |
| 203 | + _id: new ObjectId(id), |
| 204 | + outdated: false, |
| 205 | + }); |
| 206 | + if (!job) { |
| 207 | + logger.warn({ id }, "Job not found"); |
| 208 | + return null; |
| 209 | + } |
| 210 | + logger.debug({ id }, "Job fetched successfully"); |
| 211 | + return serializeJob(job as MongoJob); |
| 212 | + }); |
| 213 | +} |
0 commit comments