| | import { PrismaClient } from "@/generated/prisma"; |
| | import { prisma } from "@/lib/prisma"; |
| | import { NextRequest, NextResponse } from "next/server"; |
| |
|
| | const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; |
| |
|
| | |
| |
|
| | async function getMatchingArrayValues( |
| | column: string, |
| | search: string, |
| | client: PrismaClient |
| | ): Promise<string[]> { |
| | const allowedColumns = ["hardskills", "softskills", "certifications", "business_domain"]; |
| | if (!allowedColumns.includes(column)) throw new Error(`Invalid column: ${column}`); |
| |
|
| | const result = await client.$queryRawUnsafe<{ val: string }[]>( |
| | `SELECT DISTINCT unnest("${column}") AS val |
| | FROM cv_profile |
| | WHERE EXISTS ( |
| | SELECT 1 FROM unnest("${column}") AS elem |
| | WHERE elem ILIKE '%' || $1 || '%' |
| | )`, |
| | search |
| | ); |
| |
|
| | const lower = search.toLowerCase(); |
| | return result.map((r) => r.val).filter((v) => v.toLowerCase().includes(lower)); |
| | } |
| |
|
| | |
| | async function getProfileIdsByUserId(user_id: string): Promise<string[]> { |
| | const rows = await prisma.$queryRaw<{ profile_id: string }[]>` |
| | SELECT p.profile_id |
| | FROM cv_profile p |
| | INNER JOIN cv_file f ON f.file_id = p.file_id |
| | WHERE f.user_id = ${user_id}::uuid AND f.is_deleted = false |
| | `; |
| | return rows.map((r) => r.profile_id); |
| | } |
| |
|
| | |
| | async function buildScoreMap(criteria_id: string): Promise<Map<string, number | null>> { |
| | const scoreMap = new Map<string, number | null>(); |
| |
|
| | |
| | const rows = await prisma.$queryRaw<{ profile_id: string; score: number | null }[]>` |
| | SELECT |
| | COALESCE(s.profile_id, m.profile_id) AS profile_id, |
| | s.score |
| | FROM cv_weight w |
| | JOIN cv_matching m ON m.weight_id = w.weight_id |
| | JOIN cv_score s ON s.matching_id = m.matching_id |
| | WHERE w.criteria_id = ${criteria_id}::uuid |
| | `; |
| |
|
| | for (const row of rows) { |
| | if (row.profile_id) scoreMap.set(row.profile_id, row.score ?? null); |
| | } |
| |
|
| | return scoreMap; |
| | } |
| |
|
| | |
| |
|
| | export async function GET(request: NextRequest) { |
| | const { searchParams } = new URL(request.url); |
| |
|
| | |
| | const page = Math.max(1, Number.parseInt(searchParams.get("page") ?? "1") || 1); |
| | const limit = Math.min(100, Math.max(1, Number.parseInt(searchParams.get("limit") ?? "10") || 10)); |
| | const skip = (page - 1) * limit; |
| |
|
| | |
| | const search = searchParams.get("search"); |
| |
|
| | |
| | const criteria_id = searchParams.get("criteria_id"); |
| | if (criteria_id && !UUID_REGEX.test(criteria_id)) { |
| | return NextResponse.json({ error: "Invalid criteria_id format" }, { status: 400 }); |
| | } |
| |
|
| | |
| | const user_id = searchParams.get("user_id"); |
| | if (user_id && !UUID_REGEX.test(user_id)) { |
| | return NextResponse.json({ error: "Invalid user_id format" }, { status: 400 }); |
| | } |
| |
|
| | |
| | const domicile = searchParams.get("domicile"); |
| | const yoe = searchParams.get("yoe"); |
| | const softskills = searchParams.getAll("softskills"); |
| | const hardskills = searchParams.getAll("hardskills"); |
| | const certifications = searchParams.getAll("certifications"); |
| | const business_domain = searchParams.getAll("business_domain"); |
| |
|
| | const univ_edu_1 = searchParams.getAll("univ_edu_1"); |
| | const univ_edu_2 = searchParams.getAll("univ_edu_2"); |
| | const univ_edu_3 = searchParams.getAll("univ_edu_3"); |
| | const major_edu_1 = searchParams.getAll("major_edu_1"); |
| | const major_edu_2 = searchParams.getAll("major_edu_2"); |
| | const major_edu_3 = searchParams.getAll("major_edu_3"); |
| | const gpa_1 = searchParams.get("gpa_1"); |
| | const gpa_2 = searchParams.get("gpa_2"); |
| | const gpa_3 = searchParams.get("gpa_3"); |
| |
|
| | |
| | const sortBy = searchParams.get("sortBy") ?? "created_at"; |
| | const sortOrder = searchParams.get("sortOrder") === "asc" ? "asc" : "desc"; |
| |
|
| | |
| | const allowedSortFields = [ |
| | "fullname", "domicile", "yoe", "gpa_edu_1", "gpa_edu_2", "gpa_edu_3", |
| | "univ_edu_1", "univ_edu_2", "univ_edu_3", "major_edu_1", "major_edu_2", |
| | "major_edu_3", "created_at", |
| | ]; |
| |
|
| | const isScoreSort = sortBy === "score" && !!criteria_id; |
| | const orderBy = allowedSortFields.includes(sortBy) |
| | ? { [sortBy]: sortOrder } |
| | : { created_at: "desc" as const }; |
| |
|
| | try { |
| | |
| | const [ |
| | matchingHardskills, |
| | matchingSoftskills, |
| | matchingCertifications, |
| | matchingBusinessDomain, |
| | userProfileIds, |
| | scoreMap, |
| | ] = await Promise.all([ |
| | search ? getMatchingArrayValues("hardskills", search, prisma) : Promise.resolve([] as string[]), |
| | search ? getMatchingArrayValues("softskills", search, prisma) : Promise.resolve([] as string[]), |
| | search ? getMatchingArrayValues("certifications", search, prisma) : Promise.resolve([] as string[]), |
| | search ? getMatchingArrayValues("business_domain", search, prisma) : Promise.resolve([] as string[]), |
| | user_id ? getProfileIdsByUserId(user_id) : Promise.resolve(null as string[] | null), |
| | criteria_id ? buildScoreMap(criteria_id) : Promise.resolve(new Map<string, number | null>()), |
| | ]); |
| |
|
| | |
| | const searchFloat = search ? Number.parseFloat(search) : NaN; |
| | const searchInt = search ? Number.parseInt(search) : NaN; |
| |
|
| | const where: any = { |
| | ...(userProfileIds !== null && { profile_id: { in: userProfileIds } }), |
| |
|
| | ...(search && { |
| | OR: [ |
| | { fullname: { contains: search, mode: "insensitive" } }, |
| | { domicile: { contains: search, mode: "insensitive" } }, |
| | { univ_edu_1: { contains: search, mode: "insensitive" } }, |
| | { univ_edu_2: { contains: search, mode: "insensitive" } }, |
| | { univ_edu_3: { contains: search, mode: "insensitive" } }, |
| | { major_edu_1:{ contains: search, mode: "insensitive" } }, |
| | { major_edu_2:{ contains: search, mode: "insensitive" } }, |
| | { major_edu_3:{ contains: search, mode: "insensitive" } }, |
| | { filename: { contains: search, mode: "insensitive" } }, |
| | ...(matchingHardskills.length > 0 ? [{ hardskills: { hasSome: matchingHardskills } }] : []), |
| | ...(matchingSoftskills.length > 0 ? [{ softskills: { hasSome: matchingSoftskills } }] : []), |
| | ...(matchingCertifications.length> 0 ? [{ certifications: { hasSome: matchingCertifications} }] : []), |
| | ...(matchingBusinessDomain.length> 0 ? [{ business_domain: { hasSome: matchingBusinessDomain} }] : []), |
| | ...(!Number.isNaN(searchFloat) ? [ |
| | { gpa_edu_1: { equals: searchFloat } }, |
| | { gpa_edu_2: { equals: searchFloat } }, |
| | { gpa_edu_3: { equals: searchFloat } }, |
| | ] : []), |
| | ...(!Number.isNaN(searchInt) ? [{ yoe: { equals: searchInt } }] : []), |
| | ], |
| | }), |
| |
|
| | ...(domicile && { domicile }), |
| | ...(yoe && { yoe: { gte: Number.parseInt(yoe) } }), |
| | ...(softskills.length > 0 && { softskills: { hasSome: softskills } }), |
| | ...(hardskills.length > 0 && { hardskills: { hasSome: hardskills } }), |
| | ...(certifications.length > 0 && { certifications: { hasSome: certifications } }), |
| | ...(business_domain.length > 0 && { business_domain: { hasSome: business_domain } }), |
| |
|
| | ...(univ_edu_1.length > 0 && { univ_edu_1: { in: univ_edu_1 } }), |
| | ...(major_edu_1.length > 0 && { major_edu_1: { in: major_edu_1 } }), |
| | ...(gpa_1 && { gpa_edu_1: { gte: Number.parseFloat(gpa_1) } }), |
| |
|
| | ...(univ_edu_2.length > 0 && { univ_edu_2: { in: univ_edu_2 } }), |
| | ...(major_edu_2.length > 0 && { major_edu_2: { in: major_edu_2 } }), |
| | ...(gpa_2 && { gpa_edu_2: { gte: Number.parseFloat(gpa_2) } }), |
| |
|
| | ...(univ_edu_3.length > 0 && { univ_edu_3: { in: univ_edu_3 } }), |
| | ...(major_edu_3.length > 0 && { major_edu_3: { in: major_edu_3 } }), |
| | ...(gpa_3 && { gpa_edu_3: { gte: Number.parseFloat(gpa_3) } }), |
| | }; |
| |
|
| | |
| | const [profiles, total] = await Promise.all([ |
| | prisma.cv_profile.findMany({ |
| | where, |
| | orderBy, |
| | |
| | ...(isScoreSort ? {} : { skip, take: limit }), |
| | }), |
| | prisma.cv_profile.count({ where }), |
| | ]); |
| |
|
| | const pagination = { |
| | total, |
| | page, |
| | limit, |
| | totalPages: Math.ceil(total / limit), |
| | hasNext: page < Math.ceil(total / limit), |
| | hasPrev: page > 1, |
| | }; |
| |
|
| | |
| | if (!criteria_id) { |
| | return NextResponse.json({ data: profiles, pagination }); |
| | } |
| |
|
| | |
| | const profilesWithScore = profiles.map((profile) => ({ |
| | ...profile, |
| | score: scoreMap.get(profile.profile_id) ?? null, |
| | })); |
| |
|
| | |
| | if (isScoreSort) { |
| | profilesWithScore.sort((a, b) => { |
| | const aScore = a.score ?? -Infinity; |
| | const bScore = b.score ?? -Infinity; |
| | return sortOrder === "asc" ? aScore - bScore : bScore - aScore; |
| | }); |
| |
|
| | return NextResponse.json({ |
| | data: profilesWithScore.slice(skip, skip + limit), |
| | pagination, |
| | }); |
| | } |
| |
|
| | return NextResponse.json({ data: profilesWithScore, pagination }); |
| |
|
| | } catch (error) { |
| | const isDev = process.env.NODE_ENV !== "production"; |
| |
|
| | const message = error instanceof Error ? error.message : String(error); |
| | const stack = error instanceof Error ? error.stack : undefined; |
| |
|
| | console.error("[GET /cv-profiles]", error); |
| |
|
| | return NextResponse.json( |
| | { |
| | error: "Failed to fetch profiles", |
| | |
| | ...(isDev |
| | ? { detail: message, stack } |
| | : { detail: message } |
| | ), |
| | }, |
| | { status: 500 } |
| | ); |
| | } |
| | } |