diff --git a/src/app/(admin)/actions.ts b/src/app/(admin)/actions.ts index df01909..57252c9 100644 --- a/src/app/(admin)/actions.ts +++ b/src/app/(admin)/actions.ts @@ -7,6 +7,7 @@ import { env } from "@/env"; import { prisma } from "@/lib/prisma"; import { adminAdjustUsageLimitSchema, + adminCopyProfileSchema, adminUserIdSchema, } from "@/lib/validations"; @@ -76,3 +77,159 @@ export async function adminTriggerScrape(): Promise<{ revalidatePath("/admin/system"); return { ok: true, message: `Scrape complete — ${body.poolNew ?? 0} new to pool` }; } + +export async function adminCopyProfileToAdmin( + data: unknown, +): Promise<{ profileId: string; jobsCopied: number; applicationsCopied: number }> { + const adminUserId = await requireAdmin(); + const { profileId, mode } = adminCopyProfileSchema.parse(data); + + const sourceProfile = await prisma.profile.findUnique({ + where: { id: profileId }, + }); + + if (!sourceProfile) throw new Error("Profile not found"); + + // Fetch jobs with nested relations when copying jobs (full or reset mode) + const sourceJobs = + mode !== "metadata" + ? await prisma.job.findMany({ + where: { profileId }, + include: { + application: { + include: { tailoredResumes: true }, + }, + }, + }) + : []; + + let jobsCopied = 0; + let applicationsCopied = 0; + + const newProfile = await prisma.$transaction(async (tx) => { + // Ensure admin user row exists (may not if they never onboarded) + await tx.user.upsert({ + where: { id: adminUserId }, + create: { id: adminUserId }, + update: {}, + }); + + // Deactivate all existing admin profiles + await tx.profile.updateMany({ + where: { userId: adminUserId }, + data: { isActive: false }, + }); + + // Create new profile under admin user + const created = await tx.profile.create({ + data: { + userId: adminUserId, + name: `[copy - ${mode}] ${sourceProfile.name}`, + isActive: true, + onboardingCompletedAt: new Date(), + // Search criteria + targetRoles: sourceProfile.targetRoles, + targetLocations: sourceProfile.targetLocations, + currency: sourceProfile.currency, + targetSalaryMin: sourceProfile.targetSalaryMin, + targetSalaryMax: sourceProfile.targetSalaryMax, + requiredSkills: sourceProfile.requiredSkills, + niceToHaveSkills: sourceProfile.niceToHaveSkills, + excludedKeywords: sourceProfile.excludedKeywords, + companySize: sourceProfile.companySize, + remotePreference: sourceProfile.remotePreference, + workEligibility: sourceProfile.workEligibility, + // Resume & contact + masterResume: sourceProfile.masterResume, + resumeLastEdited: sourceProfile.resumeLastEdited, + curriculumVitae: sourceProfile.curriculumVitae, + displayName: sourceProfile.displayName, + email: sourceProfile.email, + phone: sourceProfile.phone, + location: sourceProfile.location, + linkedinUrl: sourceProfile.linkedinUrl, + portfolioUrl: sourceProfile.portfolioUrl, + githubUrl: sourceProfile.githubUrl, + skills: sourceProfile.skills, + // Writing rules + protectedPhrases: sourceProfile.protectedPhrases, + bannedPhrases: sourceProfile.bannedPhrases, + verifiedMetrics: sourceProfile.verifiedMetrics, + neverClaim: sourceProfile.neverClaim, + // AI model overrides + customTailorModel: sourceProfile.customTailorModel, + customAnalyzeModel: sourceProfile.customAnalyzeModel, + customExtractModel: sourceProfile.customExtractModel, + // Scraper config + scraperEnabled: sourceProfile.scraperEnabled, + scraperSources: sourceProfile.scraperSources, + scraperFrequency: sourceProfile.scraperFrequency, + }, + }); + + // Copy jobs + applications + tailored resumes in full mode + for (const sourceJob of sourceJobs) { + const newJob = await tx.job.create({ + data: { + profileId: created.id, + jobPoolId: sourceJob.jobPoolId, + aiScore: sourceJob.aiScore, + aiStatus: sourceJob.aiStatus, + aiSummary: sourceJob.aiSummary, + aiMatchPoints: sourceJob.aiMatchPoints, + aiGapPoints: sourceJob.aiGapPoints, + aiAnalyzedAt: sourceJob.aiAnalyzedAt, + aiModel: sourceJob.aiModel, + feedStatus: mode === "reset" ? "NEW" : sourceJob.feedStatus, + viewedAt: mode === "reset" ? null : sourceJob.viewedAt, + userNotes: sourceJob.userNotes, + matchTier: sourceJob.matchTier, + matchConfidence: sourceJob.matchConfidence, + }, + }); + jobsCopied++; + + if (sourceJob.application) { + const newApplication = await tx.application.create({ + data: { + profileId: created.id, + jobId: newJob.id, + status: sourceJob.application.status, + statusUpdatedAt: sourceJob.application.statusUpdatedAt, + appliedAt: sourceJob.application.appliedAt, + interviewDates: sourceJob.application.interviewDates, + offerReceivedAt: sourceJob.application.offerReceivedAt, + decisionAt: sourceJob.application.decisionAt, + notes: sourceJob.application.notes, + salaryOffered: sourceJob.application.salaryOffered, + recruiterName: sourceJob.application.recruiterName, + recruiterEmail: sourceJob.application.recruiterEmail, + followUpAt: sourceJob.application.followUpAt, + exportedResumeMarkdown: + sourceJob.application.exportedResumeMarkdown, + exportedAt: sourceJob.application.exportedAt, + }, + }); + applicationsCopied++; + + for (const sourceResume of sourceJob.application.tailoredResumes) { + await tx.tailoredResume.create({ + data: { + applicationId: newApplication.id, + markdown: sourceResume.markdown, + generatedBy: sourceResume.generatedBy, + wasExported: sourceResume.wasExported, + exportedAt: sourceResume.exportedAt, + promptSnapshot: sourceResume.promptSnapshot, + }, + }); + } + } + } + + return created; + }, { timeout: 30000 }); + + revalidatePath("/admin/users"); + return { profileId: newProfile.id, jobsCopied, applicationsCopied }; +} diff --git a/src/app/(admin)/admin/users/[userId]/page.tsx b/src/app/(admin)/admin/users/[userId]/page.tsx index ede0266..efd1245 100644 --- a/src/app/(admin)/admin/users/[userId]/page.tsx +++ b/src/app/(admin)/admin/users/[userId]/page.tsx @@ -2,8 +2,10 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { format, formatDistanceToNow } from "date-fns"; +import { env } from "@/env"; import { getAdminUserDetail } from "@/lib/admin-queries"; import { AdminStatCard } from "@/components/admin/AdminStatCard"; +import { CopyProfileButton } from "@/components/admin/CopyProfileButton"; import { UserDetailActions } from "@/components/admin/UserDetailActions"; export default async function AdminUserDetailPage({ @@ -16,6 +18,7 @@ export default async function AdminUserDetailPage({ if (!user) notFound(); const isDisabled = user.disabledAt !== null; + const isOwnAccount = userId === env.ADMIN_USER_ID; return (
@@ -84,13 +87,16 @@ export default async function AdminUserDetailPage({ No profiles created yet.

) : ( -
+
+ {!isOwnAccount && ( + + )} @@ -106,6 +112,14 @@ export default async function AdminUserDetailPage({ + {!isOwnAccount && ( + + )} ))} diff --git a/src/components/admin/CopyProfileButton.tsx b/src/components/admin/CopyProfileButton.tsx new file mode 100644 index 0000000..50c9312 --- /dev/null +++ b/src/components/admin/CopyProfileButton.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useTransition, useState, useEffect, useRef } from "react"; + +import { adminCopyProfileToAdmin } from "@/app/(admin)/actions"; + +interface CopyProfileButtonProps { + profileId: string; + profileName: string; +} + +export function CopyProfileButton({ + profileId, + profileName, +}: CopyProfileButtonProps) { + const [isPending, startTransition] = useTransition(); + const [isOpen, setIsOpen] = useState(false); + const [result, setResult] = useState<"success" | "error" | null>(null); + const containerRef = useRef(null); + + // Close dropdown on outside click + useEffect(() => { + if (!isOpen) return; + + function handleClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + } + } + + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [isOpen]); + + function handleCopy(mode: "full" | "metadata" | "reset") { + setIsOpen(false); + startTransition(async () => { + try { + await adminCopyProfileToAdmin({ profileId, mode }); + setResult("success"); + } catch { + setResult("error"); + } + }); + } + + // Clear result message after 2 seconds + useEffect(() => { + if (!result) return; + + const timeout = setTimeout(() => setResult(null), 2000); + return () => clearTimeout(timeout); + }, [result]); + + const buttonLabel = isPending + ? "Copying..." + : result === "success" + ? "Copied!" + : result === "error" + ? "Failed" + : "Copy"; + + return ( +
+ + + {isOpen && ( +
+ + + +
+ )} +
+ ); +} diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 33a0c0e..3a2eb36 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -204,3 +204,9 @@ export const adminAdjustUsageLimitSchema = z.object({ export const adminUserIdSchema = z.object({ userId: z.string().min(1), }); + +// ── Admin: copy profile to admin account ───────────────────────────────────── +export const adminCopyProfileSchema = z.object({ + profileId: z.string().cuid(), + mode: z.enum(["full", "metadata", "reset"]), +});
Name Jobs ApplicationsActions
{profile._count.applications} + +