diff --git a/docs/audit-findings.md b/docs/audit-findings.md index 23680df..c6369c0 100644 --- a/docs/audit-findings.md +++ b/docs/audit-findings.md @@ -8,7 +8,8 @@ files, line references, root cause, and fix instructions. A cleared session can work through these top-to-bottom. > **CRITICAL items (1–4) were fixed in PR `fix/critical-security-fixes`.** -> Start from HIGH (#5) onward. +> **HIGH items #5 and #6 also fixed** on the same branch. +> Start from HIGH (#7) onward. --- @@ -20,6 +21,31 @@ work through these top-to-bottom. 4. Run `pnpm tsc --noEmit` after each change. 5. Group related findings into one branch/PR where noted. +### Parallelization Guide + +The remaining findings can be split across independent agents/branches: + +| Agent | Branch | Findings | Files touched | +|-------|--------|----------|---------------| +| **A: Remaining HIGH** | `fix/critical-security-fixes` (continue) | #7–#11 | `store.ts`, `tailor/save/route.ts`, `ImportJobModal.tsx`, `analyze/route.ts`, `tailor/route.ts`, `middleware.ts` | +| **B: Redundancies** | `refactor/extract-shared-helpers` | #12–#18 | New: `src/lib/usage.ts`, `src/lib/auth-helpers.ts`, `src/components/pipeline/use-pipeline-editing.ts`, `src/lib/pipeline-constants.ts`. Modified: all API routes, server actions, pipeline components | +| **C: Inconsistencies** | `fix/consistency-cleanup` | #19–#26 | `tailor/route.ts`, `extract/route.ts`, `dashboard/actions.ts`, `pipeline/actions.ts`, `validations.ts`, admin actions, `(dashboard)/layout.tsx` | +| **D: Performance** | `perf/query-optimization` | #27–#33 | New migration, `match-sql.ts`, `adzuna.ts`, `usajobs.ts`, `actions-sync.ts`, `JobFeed.tsx`, `scrape/route.ts` | +| **E: Data Syncing** | `fix/sync-gaps` | #34–#39 | `dashboard/actions.ts`, `scrape/route.ts`, `analyze/route.ts`, `match-pipeline.ts`, `store.ts`, `ProfileSwitcher.tsx` | +| **F: Security** | `fix/security-hardening` | #40–#43 | `OnboardingWizard.tsx`, `middleware.ts`, `rate-limit.ts`, `analyze/route.ts`, `match/route.ts` | +| **G: Low cleanup** | `chore/opportunistic-cleanup` | #44–#60 | Scattered small changes across many files | + +**Conflict zones:** Agents A, B, C, and E all touch `dashboard/actions.ts` and `store.ts`. Run A first, then B+C+D+E+F+G can run in parallel if they each create their own branch. Merge A first, then rebase others. + +**Safest parallel split:** D (performance), F (security), and G (cleanup) have minimal overlap with each other and the rest. B (redundancies) and C (inconsistencies) touch many of the same files but different lines. + +### Important context for agents + +- **`retryServerAction` now returns `T | null`** (not `boolean`). Callers must check `result === null` for failure (not `!result`), because `void` actions return `undefined` on success which is falsy. +- **`JobWithApplication` type changed.** `jobPool` is now a select subset (no `description`, no `rawData`). The `jobPoolSummarySelect` constant in `src/types/index.ts` defines the selected fields. Any code needing `description` must fetch it separately (see job detail page pattern: server component passes `description` prop). +- **`ApplicationWithJob` type also changed** — its nested `job.jobPool` uses the same summary select. +- **Store `partialize`** no longer strips `rawData` manually since the data never arrives from the server. + --- ## CRITICAL — Fixed in `fix/critical-security-fixes` @@ -69,57 +95,22 @@ now fail if any client component imports this module. ### 5. Over-fetching `jobPool.description` + `rawData` on every dashboard load -**Files:** `src/app/(dashboard)/actions-sync.ts:39-48` - -**Problem:** `include: { jobPool: true }` fetches ALL columns including -`description` (5-20KB) and `rawData` (10-50KB) per job. For 200 jobs = 2-10MB -per page load. This data is never displayed on the dashboard. - -**Fix:** -1. In `fetchDashboardData()`, replace `include: { jobPool: true }` with: - ```ts - include: { - jobPool: { - select: { - id: true, title: true, company: true, location: true, - locationType: true, url: true, source: true, postedAt: true, - skills: true, salaryMin: true, salaryMax: true, currency: true, - jobType: true, country: true, - }, - }, - application: { select: { status: true } }, - } - ``` -2. Do the same for the applications query's nested `job.jobPool`. -3. In `src/lib/store.ts` `partialize` function (~line 594), also strip - `description` from persisted jobs (currently only strips `rawData`). -4. Update the `JobWithApplication` type in `src/types/index.ts` if needed - (may need a `JobPoolSummary` type without `description`/`rawData`). +**Status: FIXED** -**Impact:** Biggest single performance win — reduces dashboard data transfer -by 80-90%. +Created `jobPoolSummarySelect` constant in `src/types/index.ts`. Updated +`fetchDashboardData()`, `getMoreJobs()`, and both type definitions to use +selective `jobPool` queries excluding `description` and `rawData`. Job detail +page now receives `description` as a server-component prop. Store `partialize` +simplified since heavy fields are never fetched. ### 6. Optimistic application ID never replaced after `toggleSaveJob` -**Files:** `src/lib/store.ts:230,273-274` - -**Problem:** Saving a job creates an optimistic application with -`id: "optimistic-{jobId}"`. The server returns `{ applicationId }` with the -real DB ID, but the store never swaps it in. If the user immediately opens -Pipeline and edits that application, server actions fail. +**Status: FIXED** -**Fix:** In the `toggleSaveJob` store action's `.then()` callback (around line -273), after the server action succeeds, update the application's ID: -```ts -// After successful save, replace optimistic ID with real one -if (result.applicationId) { - set((state) => ({ - applications: state.applications.map((app) => - app.id === `optimistic-${jobId}` ? { ...app, id: result.applicationId! } : app - ), - })); -} -``` +`retryServerAction` is now generic (``) returning `T | null` instead of +`boolean`. The `toggleSaveJob` callback captures the server response and swaps +`optimistic-{jobId}` with the real `applicationId`. All other store callers +updated to check `result === null` for failure. ### 7. `updateAppStatus` doesn't mirror server-side `feedStatus: ARCHIVED` diff --git a/prisma/migrations/20260325000000_add_trgm_indexes/migration.sql b/prisma/migrations/20260325000000_add_trgm_indexes/migration.sql new file mode 100644 index 0000000..7b2f520 --- /dev/null +++ b/prisma/migrations/20260325000000_add_trgm_indexes/migration.sql @@ -0,0 +1,8 @@ +-- Enable pg_trgm extension for trigram-based indexing +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Trigram GIN index on job_pool.title for LIKE ANY(...) in match-sql.ts +CREATE INDEX idx_job_pool_title_trgm ON job_pool USING gin (title gin_trgm_ops); + +-- Trigram GIN index on job_pool.location for LIKE ANY(...) in match-sql.ts +CREATE INDEX idx_job_pool_location_trgm ON job_pool USING gin (location gin_trgm_ops); diff --git a/src/app/(admin)/actions.ts b/src/app/(admin)/actions.ts index 57252c9..5e050aa 100644 --- a/src/app/(admin)/actions.ts +++ b/src/app/(admin)/actions.ts @@ -19,19 +19,21 @@ async function requireAdmin() { export async function adminAdjustUsageLimit(data: unknown) { await requireAdmin(); - const parsed = adminAdjustUsageLimitSchema.parse(data); + const parsed = adminAdjustUsageLimitSchema.safeParse(data); + if (!parsed.success) throw new Error("Invalid input"); await prisma.usage.update({ - where: { userId: parsed.userId }, - data: { monthlyLimitInputTokens: parsed.monthlyLimitInputTokens }, + where: { userId: parsed.data.userId }, + data: { monthlyLimitInputTokens: parsed.data.monthlyLimitInputTokens }, }); revalidatePath("/admin/users"); } export async function adminDisableUser(data: unknown) { await requireAdmin(); - const { userId } = adminUserIdSchema.parse(data); + const parsed = adminUserIdSchema.safeParse(data); + if (!parsed.success) throw new Error("Invalid input"); await prisma.user.update({ - where: { id: userId }, + where: { id: parsed.data.userId }, data: { disabledAt: new Date() }, }); revalidatePath("/admin/users"); @@ -39,9 +41,10 @@ export async function adminDisableUser(data: unknown) { export async function adminEnableUser(data: unknown) { await requireAdmin(); - const { userId } = adminUserIdSchema.parse(data); + const parsed = adminUserIdSchema.safeParse(data); + if (!parsed.success) throw new Error("Invalid input"); await prisma.user.update({ - where: { id: userId }, + where: { id: parsed.data.userId }, data: { disabledAt: null }, }); revalidatePath("/admin/users"); @@ -49,9 +52,10 @@ export async function adminEnableUser(data: unknown) { export async function adminResetMonthlyUsage(data: unknown) { await requireAdmin(); - const { userId } = adminUserIdSchema.parse(data); + const parsed = adminUserIdSchema.safeParse(data); + if (!parsed.success) throw new Error("Invalid input"); await prisma.usage.update({ - where: { userId }, + where: { userId: parsed.data.userId }, data: { currentMonthInputTokens: 0, currentMonthOutputTokens: 0 }, }); revalidatePath("/admin/users"); @@ -82,7 +86,9 @@ export async function adminCopyProfileToAdmin( data: unknown, ): Promise<{ profileId: string; jobsCopied: number; applicationsCopied: number }> { const adminUserId = await requireAdmin(); - const { profileId, mode } = adminCopyProfileSchema.parse(data); + const parsed = adminCopyProfileSchema.safeParse(data); + if (!parsed.success) throw new Error("Invalid input"); + const { profileId, mode } = parsed.data; const sourceProfile = await prisma.profile.findUnique({ where: { id: profileId }, diff --git a/src/app/(admin)/admin/feedback/page.tsx b/src/app/(admin)/admin/feedback/page.tsx index 3c3d77a..61d54e4 100644 --- a/src/app/(admin)/admin/feedback/page.tsx +++ b/src/app/(admin)/admin/feedback/page.tsx @@ -1,8 +1,11 @@ import Link from "next/link"; import { formatDistanceToNow } from "date-fns"; +import type { Metadata } from "next"; import { getAdminFeedbackList } from "@/lib/admin-queries"; +export const metadata: Metadata = { title: "Feedback" }; + type FeedbackMetadata = { pathname?: string; profileName?: string; diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index af8ce25..58c5175 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -1,6 +1,9 @@ import { formatDistanceToNow } from "date-fns"; +import type { Metadata } from "next"; import { AdminStatCard } from "@/components/admin/AdminStatCard"; + +export const metadata: Metadata = { title: "Admin Overview" }; import { getAdminOverviewStats, getRecentScrapeRuns, diff --git a/src/app/(admin)/admin/users/[userId]/page.tsx b/src/app/(admin)/admin/users/[userId]/page.tsx index efd1245..5c16fb6 100644 --- a/src/app/(admin)/admin/users/[userId]/page.tsx +++ b/src/app/(admin)/admin/users/[userId]/page.tsx @@ -1,8 +1,11 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { format, formatDistanceToNow } from "date-fns"; +import type { Metadata } from "next"; import { env } from "@/env"; + +export const metadata: Metadata = { title: "User Detail" }; import { getAdminUserDetail } from "@/lib/admin-queries"; import { AdminStatCard } from "@/components/admin/AdminStatCard"; import { CopyProfileButton } from "@/components/admin/CopyProfileButton"; diff --git a/src/app/(admin)/admin/users/page.tsx b/src/app/(admin)/admin/users/page.tsx index c9109c7..301feba 100644 --- a/src/app/(admin)/admin/users/page.tsx +++ b/src/app/(admin)/admin/users/page.tsx @@ -1,10 +1,11 @@ import Link from "next/link"; import { formatDistanceToNow } from "date-fns"; +import type { Metadata } from "next"; import { getAdminUserList } from "@/lib/admin-queries"; import { UserSearchBar } from "@/components/admin/UserSearchBar"; -export const metadata = { title: "Users" }; +export const metadata: Metadata = { title: "Users" }; export default async function AdminUsersPage({ searchParams, diff --git a/src/app/(dashboard)/actions-sync.ts b/src/app/(dashboard)/actions-sync.ts index 638205c..f9f6cf8 100644 --- a/src/app/(dashboard)/actions-sync.ts +++ b/src/app/(dashboard)/actions-sync.ts @@ -4,6 +4,7 @@ import { auth } from "@clerk/nextjs/server"; import { prisma } from "@/lib/prisma"; import { getActiveProfile } from "@/lib/get-active-profile"; import { getFollowUpCount } from "@/app/(dashboard)/pipeline/actions"; +import { jobPoolSummarySelect } from "@/types"; /** * Fetches all dashboard data for the authenticated user. @@ -38,12 +39,17 @@ export async function fetchDashboardData() { }), prisma.job.findMany({ where: { profileId: activeProfile.id }, - include: { jobPool: true, application: { select: { status: true } } }, + include: { + jobPool: { select: jobPoolSummarySelect }, + application: { select: { status: true } }, + }, orderBy: { createdAt: "desc" }, }), prisma.application.findMany({ where: { profileId: activeProfile.id }, - include: { job: { include: { jobPool: true } } }, + include: { + job: { include: { jobPool: { select: jobPoolSummarySelect } } }, + }, orderBy: { updatedAt: "desc" }, }), getFollowUpCount(userId), diff --git a/src/app/(dashboard)/dashboard/actions.ts b/src/app/(dashboard)/dashboard/actions.ts index 10438c7..7e16122 100644 --- a/src/app/(dashboard)/dashboard/actions.ts +++ b/src/app/(dashboard)/dashboard/actions.ts @@ -3,7 +3,6 @@ import { auth } from "@clerk/nextjs/server"; import type { AiStatus } from "@prisma/client"; import { headers } from "next/headers"; -import { appendFileSync } from "fs"; import { revalidatePath, revalidateTag } from "next/cache"; import { prisma } from "@/lib/prisma"; import { openrouter } from "@/lib/openrouter"; @@ -11,8 +10,12 @@ import { getModels } from "@/lib/models"; import { buildWhereClause, buildOrderBy } from "@/lib/jobs"; import { buildAnalysisSystemPrompt, parseAiAnalysisResponse } from "@/lib/ai-analysis"; import { checkRateLimit } from "@/lib/rate-limit"; +import { logAiContext } from "@/lib/ai-logging"; +import { incrementUsage, checkUsageLimit } from "@/lib/usage"; +import { requireProfile, requireProfileWithUsage } from "@/lib/auth-helpers"; import { updateCustomJobSchema } from "@/lib/validations"; import type { SortOption } from "@/lib/jobs"; +import { jobPoolSummarySelect } from "@/types"; import type { JobWithApplication } from "@/types"; export async function getMoreJobs( @@ -21,20 +24,13 @@ export async function getMoreJobs( filter: string, sort: string, ): Promise<{ jobs: JobWithApplication[]; nextCursor: string | null }> { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - // Verify the profileId belongs to the authenticated user - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - }); - if (!profile) throw new Error("Profile not found"); + await requireProfile(profileId); const safeSort: SortOption = sort === "newest" ? "newest" : "match"; const jobs = await prisma.job.findMany({ where: buildWhereClause(profileId, filter), - include: { jobPool: true, application: { select: { status: true } } }, + include: { jobPool: { select: jobPoolSummarySelect }, application: { select: { status: true } } }, orderBy: buildOrderBy(safeSort), take: 25, cursor: { id: cursor }, @@ -52,13 +48,7 @@ export async function toggleSaveJob( profileId: string, save: boolean ): Promise<{ applicationId?: string }> { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - }); - if (!profile) throw new Error("Profile not found"); + await requireProfile(profileId); // Scope to profileId to prevent cross-user mutation (IDOR) const updated = await prisma.job.updateMany({ @@ -89,13 +79,7 @@ export async function ignoreJob( jobId: string, profileId: string ): Promise { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - }); - if (!profile) throw new Error("Profile not found"); + await requireProfile(profileId); const updated = await prisma.job.updateMany({ where: { id: jobId, profileId }, @@ -111,13 +95,7 @@ export async function unignoreJob( profileId: string, restoreStatus: string ): Promise { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - }); - if (!profile) throw new Error("Profile not found"); + await requireProfile(profileId); const validStatuses = ["NEW", "SAVED"] as const; const feedStatus = (validStatuses as readonly string[]).includes(restoreStatus) @@ -137,13 +115,7 @@ export async function batchIgnoreJobs( jobIds: string[], profileId: string ): Promise { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - }); - if (!profile) throw new Error("Profile not found"); + await requireProfile(profileId); await prisma.job.updateMany({ where: { id: { in: jobIds }, profileId }, @@ -158,18 +130,27 @@ export async function batchSaveJobs( profileId: string, save: boolean ): Promise { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - }); - if (!profile) throw new Error("Profile not found"); + await requireProfile(profileId); await prisma.job.updateMany({ where: { id: { in: jobIds }, profileId }, data: { feedStatus: save ? "SAVED" : "NEW" }, }); + + // Create Application records for batch-saved jobs (matches toggleSaveJob behavior) + if (save) { + await prisma.$transaction( + jobIds.map((id) => + prisma.application.upsert({ + where: { jobId: id }, + create: { jobId: id, profileId, status: "INTERESTED", statusUpdatedAt: new Date() }, + update: {}, + }) + ) + ); + revalidatePath("/pipeline"); + } + revalidatePath("/dashboard"); revalidateTag("dashboard-stats"); } @@ -187,22 +168,15 @@ export async function analyzeJob( jobId: string, profileId: string, ): Promise<{ error: "CREDITS" | "UNKNOWN" } | JobScoreUpdate> { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - include: { user: { include: { usage: true } } }, - }); - if (!profile) throw new Error("Profile not found"); + const { userId, profile } = await requireProfile(profileId); const models = getModels(profile); const { allowed } = checkRateLimit(userId, "analyze", 10); if (!allowed) return { error: "UNKNOWN" as const }; - const usage = profile.user.usage; - if (usage && usage.currentMonthInputTokens >= usage.monthlyLimitInputTokens) { + const withinLimit = await checkUsageLimit(userId); + if (!withinLimit) { return { error: "CREDITS" }; } @@ -217,13 +191,7 @@ export async function analyzeJob( const h = await headers(); const host = h.get("host") ?? ""; - if (host.startsWith("localhost") || host.startsWith("127.")) { - const sep = "=".repeat(80); - appendFileSync( - "ai-context.log", - `\n${sep}\n[${new Date().toISOString()}] ANALYZE (action) — jobId: ${jobId}, title: "${job.jobPool.title}"\n\n## SYSTEM\n${systemPrompt}\n\n## USER\n${userMsg}\n`, - ); - } + logAiContext(host, `ANALYZE (action) — jobId: ${jobId}`, job.jobPool.title, systemPrompt, userMsg); try { const response = await openrouter.chat.completions.create({ @@ -232,7 +200,7 @@ export async function analyzeJob( { role: "system", content: systemPrompt }, { role: "user", content: userMsg }, ], - max_tokens: 1500, + max_completion_tokens: 1500, }); const text = response.choices[0]?.message?.content ?? ""; @@ -257,26 +225,7 @@ export async function analyzeJob( const inputTokens = response.usage?.prompt_tokens ?? 0; const outputTokens = response.usage?.completion_tokens ?? 0; - if (inputTokens > 0) { - await prisma.usage.upsert({ - where: { userId }, - create: { - userId, - totalInputTokens: inputTokens, - totalOutputTokens: outputTokens, - currentMonthInputTokens: inputTokens, - currentMonthOutputTokens: outputTokens, - analysisCallCount: 1, - }, - update: { - totalInputTokens: { increment: inputTokens }, - totalOutputTokens: { increment: outputTokens }, - currentMonthInputTokens: { increment: inputTokens }, - currentMonthOutputTokens: { increment: outputTokens }, - analysisCallCount: { increment: 1 }, - }, - }); - } + await incrementUsage(userId, inputTokens, outputTokens, "analysis"); revalidatePath("/dashboard"); return { @@ -297,13 +246,7 @@ export async function discardAnalysis( jobId: string, profileId: string, ): Promise { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - }); - if (!profile) throw new Error("Profile not found"); + await requireProfile(profileId); const job = await prisma.job.findFirst({ where: { id: jobId, profileId } }); if (!job) throw new Error("Job not found"); @@ -330,12 +273,12 @@ export async function discardAnalysis( export async function updateCustomJob( data: unknown -): Promise<{ error?: string }> { +): Promise { const { userId } = await auth(); - if (!userId) return { error: "Unauthorized" }; + if (!userId) throw new Error("Unauthorized"); const parsed = updateCustomJobSchema.safeParse(data); - if (!parsed.success) return { error: "Invalid request" }; + if (!parsed.success) throw new Error("Invalid request"); const { jobId, profileId, title, company, description, location, locationType, url, jobType, salaryMin, salaryMax, currency, skills } = parsed.data; @@ -348,10 +291,10 @@ export async function updateCustomJob( jobPool: { select: { source: true, id: true } }, }, }); - if (!job || job.profile.userId !== userId) return { error: "Not found" }; + if (!job || job.profile.userId !== userId) throw new Error("Not found"); // Only CUSTOM-sourced jobs can be edited - if (job.jobPool.source !== "CUSTOM") return { error: "Only imported jobs can be edited" }; + if (job.jobPool.source !== "CUSTOM") throw new Error("Only imported jobs can be edited"); await prisma.jobPool.update({ where: { id: job.jobPool.id }, @@ -372,7 +315,6 @@ export async function updateCustomJob( revalidatePath(`/jobs/${jobId}`); revalidatePath("/dashboard"); - return {}; } export async function updateJobNotes( @@ -380,19 +322,16 @@ export async function updateJobNotes( profileId: string, notes: string ): Promise { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - }); - if (!profile) throw new Error("Profile not found"); + await requireProfile(profileId); const updated = await prisma.job.updateMany({ where: { id: jobId, profileId }, data: { userNotes: notes.trim() || null }, }); if (updated.count === 0) throw new Error("Job not found"); + + revalidatePath("/dashboard"); + revalidatePath(`/jobs/${jobId}`); } // ─── Load more matches ──────────────────────────────────────────────────────── @@ -400,13 +339,7 @@ export async function updateJobNotes( export async function loadMoreMatches( profileId: string, ): Promise<{ added: number; remaining: number }> { - const { userId } = await auth(); - if (!userId) throw new Error("Unauthorized"); - - const profile = await prisma.profile.findFirst({ - where: { id: profileId, userId }, - }); - if (!profile) return { added: 0, remaining: 0 }; + const { profile } = await requireProfile(profileId); const { runMatchPipelineForProfile } = await import("@/lib/match-pipeline"); const result = await runMatchPipelineForProfile(profileId, profile); diff --git a/src/app/(dashboard)/jobs/[id]/_components/JobDetailClient.tsx b/src/app/(dashboard)/jobs/[id]/_components/JobDetailClient.tsx index 847929b..52dbf01 100644 --- a/src/app/(dashboard)/jobs/[id]/_components/JobDetailClient.tsx +++ b/src/app/(dashboard)/jobs/[id]/_components/JobDetailClient.tsx @@ -37,9 +37,10 @@ const JOB_TYPE_LABELS: Record = { interface JobDetailClientProps { jobId: string; + description: string | null; } -export function JobDetailClient({ jobId }: JobDetailClientProps) { +export function JobDetailClient({ jobId, description }: JobDetailClientProps) { const hydrated = useDashboardStore((s) => s.hydrated); const job = useDashboardStore((s) => s.jobs.find((j) => j.id === jobId) ?? null); const [editing, setEditing] = useState(false); @@ -166,17 +167,19 @@ export function JobDetailClient({ jobId }: JobDetailClientProps) { {editing && isCustom && ( { setSaving(true); setSaveError(null); - const result = await updateCustomJob(data); - if (result.error) { - setSaveError(result.error); - } else { + try { + await updateCustomJob(data); await sync(); setEditing(false); + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setSaving(false); } - setSaving(false); }} saving={saving} saveError={saveError} @@ -194,7 +197,7 @@ export function JobDetailClient({ jobId }: JobDetailClientProps) { className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] p-6" style={{ boxShadow: "var(--shadow-card)" }} > - + @@ -336,11 +339,13 @@ type JobForEdit = NonNullable["job function CustomJobEditForm({ job, + description, onSave, saving, saveError, }: { job: JobForEdit; + description: string; onSave: (data: unknown) => void; saving: boolean; saveError: string | null; @@ -356,7 +361,7 @@ function CustomJobEditForm({ const [salaryMax, setSalaryMax] = useState(pool.salaryMax?.toString() ?? ""); const [currency, setCurrency] = useState(pool.currency ?? ""); const [skills, setSkills] = useState(pool.skills.join(", ")); - const [description, setDescription] = useState(pool.description); + const [editDescription, setEditDescription] = useState(description ?? ""); function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -365,7 +370,7 @@ function CustomJobEditForm({ profileId: job.profileId, title: title.trim(), company: company.trim(), - description: description.trim(), + description: editDescription.trim(), location: location.trim() || null, locationType: locationType || null, url: url.trim() || null, @@ -447,8 +452,8 @@ function CustomJobEditForm({