Skip to content
157 changes: 157 additions & 0 deletions src/app/(admin)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { env } from "@/env";
import { prisma } from "@/lib/prisma";
import {
adminAdjustUsageLimitSchema,
adminCopyProfileSchema,
adminUserIdSchema,
} from "@/lib/validations";

Expand Down Expand Up @@ -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 };
}
16 changes: 15 additions & 1 deletion src/app/(admin)/admin/users/[userId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 (
<div className="space-y-8">
Expand Down Expand Up @@ -84,13 +87,16 @@ export default async function AdminUserDetailPage({
No profiles created yet.
</p>
) : (
<div className="overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--bg-card)]">
<div className="overflow-visible rounded-xl border border-[var(--border)] bg-[var(--bg-card)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3 text-right">Jobs</th>
<th className="px-4 py-3 text-right">Applications</th>
{!isOwnAccount && (
<th className="px-4 py-3 text-right">Actions</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
Expand All @@ -106,6 +112,14 @@ export default async function AdminUserDetailPage({
<td className="px-4 py-3 text-right tabular-nums">
{profile._count.applications}
</td>
{!isOwnAccount && (
<td className="px-4 py-3 text-right">
<CopyProfileButton
profileId={profile.id}
profileName={profile.name}
/>
</td>
)}
</tr>
))}
</tbody>
Expand Down
128 changes: 128 additions & 0 deletions src/components/admin/CopyProfileButton.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="relative inline-block">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
disabled={isPending || result !== null}
className={`inline-flex items-center gap-1 rounded-lg border border-[var(--border)] px-3 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 ${
result === "success"
? "text-emerald-500"
: result === "error"
? "text-red-500"
: "text-[var(--text)] hover:bg-[var(--bg-subtle)]"
}`}
title={`Copy "${profileName}" to your account`}
>
{buttonLabel}
{!isPending && result === null && (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className="text-[var(--text-muted)]"
>
<path
d="M3 5L6 8L9 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>

{isOpen && (
<div className="absolute right-0 top-full z-10 mt-1 min-w-[12rem] rounded-lg border border-[var(--border)] bg-[var(--bg-card)] shadow-lg">
<button
type="button"
onClick={() => handleCopy("full")}
className="w-full cursor-pointer px-3 py-2 text-left text-sm text-[var(--text)] transition-colors hover:bg-[var(--bg-subtle)]"
>
Full copy (with jobs)
</button>
<button
type="button"
onClick={() => handleCopy("reset")}
className="w-full cursor-pointer px-3 py-2 text-left text-sm text-[var(--text)] transition-colors hover:bg-[var(--bg-subtle)]"
>
Reset copy (all jobs as new)
</button>
<button
type="button"
onClick={() => handleCopy("metadata")}
className="w-full cursor-pointer px-3 py-2 text-left text-sm text-[var(--text)] transition-colors hover:bg-[var(--bg-subtle)]"
>
Metadata only
</button>
</div>
)}
</div>
);
}
6 changes: 6 additions & 0 deletions src/lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
});
Loading