File manager - Edit - /var/www/ratemypay_dev/app/Http/Controllers/JobDescriptionController.php
Back
<?php namespace App\Http\Controllers; use App\Models\Department; use App\Models\JobDescription; use App\Models\JobDescriptionActivityLog; use App\Models\JobDescriptionComment; use App\Models\JobDescriptionSection; use App\Models\JobDescriptionValidationCheck; use App\Models\JobDescriptionVersion; use App\Services\JDWizardAIService; use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\View\View; use PhpOffice\PhpWord\IOFactory; use PhpOffice\PhpWord\PhpWord; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; /** * Stub controller for JD Wizard UI preview. * TODO: Backend developer to replace with real logic, model bindings, and repository. */ class JobDescriptionController extends Controller { public function __construct(private readonly JDWizardAIService $aiService) {} public function index(Request $request): View { $user = Auth::user(); // ── Base query scoped to the authenticated user ─────────────────────── // Org admins see every JD belonging to their org. // Everyone else (including org-less users) sees only their own JDs. $query = JobDescription::with('creator') ->when( $user->organization_id, fn ($q) => $q->where('organization_id', $user->organization_id), fn ($q) => $q->where('created_by', $user->id) ); // ── Search ──────────────────────────────────────────────────────────── // Reads ?search= from the query string if ($request->filled('search')) { $search = $request->input('search'); $query->where(function ($q) use ($search) { $q->where('job_title', 'like', "%{$search}%") ->orWhere('department', 'like', "%{$search}%") ->orWhere(DB::raw("CONCAT('JD-', LPAD(id, 3, '0'))"), 'like', "%{$search}%"); }); } // ── Department filter ───────────────────────────────────────────────── // Reads ?department= from the query string if ($request->filled('department') && $request->input('department') !== 'All Departments') { $query->where('department', $request->input('department')); } // ── Status filter ───────────────────────────────────────────────────── // Reads ?status= from the query string if ($request->filled('status') && $request->input('status') !== 'All Status') { $statusMap = [ 'Draft' => JobDescription::STATUS_DRAFT, 'Approved' => JobDescription::STATUS_APPROVED, 'Published' => JobDescription::STATUS_PUBLISHED, 'Unpublished' => JobDescription::STATUS_DRAFT, // treated as draft in our model ]; $mapped = $statusMap[$request->input('status')] ?? null; if ($mapped) { $query->where('status', $mapped); } } // ── Stats cards (computed from the scoped base, before filters) ─────── // Clone the scoped (but un-filtered) query for accurate card counts $scopedBase = JobDescription::when( $user->organization_id, fn ($q) => $q->where('organization_id', $user->organization_id), fn ($q) => $q->where('created_by', $user->id) ); $stats = [ 'total' => (clone $scopedBase)->count(), 'approved' => (clone $scopedBase)->where('status', JobDescription::STATUS_APPROVED)->count(), 'published' => (clone $scopedBase)->where('status', JobDescription::STATUS_PUBLISHED)->count(), 'drafts' => (clone $scopedBase)->where('status', JobDescription::STATUS_DRAFT)->count(), ]; // ── Per-page ────────────────────────────────────────────────────────── // Reads ?per_page= from the query string; capped between 5 and 100 $perPage = $request->input('per_page', 10); $perPage = is_numeric($perPage) ? min(max((int) $perPage, 5), 100) : 10; // ── Paginate and preserve all active query string params ────────────── $jobDescriptions = $query->latest()->paginate($perPage)->withQueryString(); // Append the formatted job_id to each item so the blade can render it $jobDescriptions->getCollection()->transform(function ($jd) { $jd->job_id = 'JD-' . str_pad($jd->id, 3, '0', STR_PAD_LEFT); $jd->author = $jd->creator?->name ?? '—'; return $jd; }); // ── Department list for the filter datalist ─────────────────────────── // Pull distinct departments actually used by JDs visible to this user, // merged with the global seeded list so new values still appear. $usedDepartments = (clone $scopedBase) ->whereNotNull('department') ->distinct() ->pluck('department'); $seededDepartments = Department::ordered()->pluck('name'); $departments = $usedDepartments->merge($seededDepartments) ->unique() ->sort() ->values(); // Pass total unfiltered count so the blade can show filters even when // the current filter returns zero results $totalUnfiltered = $stats['total']; return view('dashboard.job-descriptions.index', compact( 'stats', 'jobDescriptions', 'totalUnfiltered', 'departments' )); } public function templates(): View { return view('dashboard.job-descriptions.templates'); } public function create(): View { // $departments = Department::ordered()->pluck('name'); $departments = JobDescription::query() ->whereNotNull('department') ->where('department', '!=', '') ->distinct() ->orderBy('department') ->pluck('department'); return view('dashboard.job-descriptions.create', compact('departments')); } public function store(Request $request) { $validated = $request->validate([ 'job_title' => 'required|string|max:255', 'department' => 'required|string|max:255', 'level' => 'required|string|max:255', 'location' => 'required|string|max:255', 'employment_type' => 'required|string|max:100', ]); try { $result = DB::transaction(function () use ($validated) { // 1. Create the draft $jd = JobDescription::create([ 'organization_id' => Auth::user()->organization_id, // nullable 'job_title' => $validated['job_title'], 'department' => $validated['department'], 'level' => $validated['level'], 'location' => $validated['location'], 'employment_type' => $validated['employment_type'], 'status' => JobDescription::STATUS_DRAFT, 'created_by' => Auth::id(), ]); // 2. Log draft created JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_DRAFT_CREATED, 'meta' => null, ]); // 3. Generate AI sections $aiSections = $this->aiService->generateJD($jd); foreach (JobDescriptionSection::SECTION_TYPES as $index => $sectionType) { $raw = $aiSections[$sectionType] ?? ''; // The AI may return a section as an array of bullet strings // or as a plain string. Normalise to a single string so the // longText column never receives an array. if (is_array($raw)) { $content = implode("\n", array_map('strval', $raw)); } else { $content = (string) $raw; } JobDescriptionSection::create([ 'job_description_id' => $jd->id, 'section_type' => $sectionType, 'title' => null, 'content' => $content, 'source' => JobDescriptionSection::SOURCE_AI, 'version' => 1, 'is_core' => true, 'sort_order' => $index, ]); } // 4. Log AI generation complete JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_AI_GENERATION_COMPLETE, 'meta' => null, ]); // 5. Save initial version snapshot JobDescriptionVersion::create([ 'job_description_id' => $jd->id, 'version_number' => 1, 'snapshot_json' => $jd->fresh()->toSnapshot(), 'created_by' => Auth::id(), ]); return $jd; }); return redirect()->route('dashboard.job-descriptions.show', $result->id); return redirect()->route('dashboard.job-descriptions.generating', $result->id); } catch (\Throwable $e) { Log::error('[JobDescriptionController] store failed', [ 'error' => $e->getMessage(), ]); return back() ->withInput() ->withErrors(['error' => 'Failed to generate job description. Please try again.']); } } public function show($jobDescription): View { $jobDescription = JobDescription::with([ 'sections' => fn ($q) => $q->orderBy('sort_order'), 'creator', ])->findOrFail($jobDescription); $this->authorizeAccess($jobDescription); // Map sections into the shape the blade expects $roleSummary = $jobDescription->getSection(JobDescriptionSection::TYPE_ROLE_SUMMARY)?->content ?? ''; $responsibilities = $jobDescription->getSection(JobDescriptionSection::TYPE_KEY_RESPONSIBILITIES)?->content ?? ''; $requiredSkills = $jobDescription->getSection(JobDescriptionSection::TYPE_REQUIRED_SKILLS)?->content ?? ''; $qualifications = $jobDescription->getSection(JobDescriptionSection::TYPE_QUALIFICATIONS)?->content ?? ''; $competencies = $jobDescription->getSection(JobDescriptionSection::TYPE_CORE_COMPETENCIES)?->content ?? ''; // Parse bullet-point lines into arrays for @foreach rendering in blade // Strip leading bullet markers (-, •, *, numbered "1." etc.) so the // blade's own bullet/number rendering is used consistently. $parseLines = fn (string $text): array => array_values( array_filter( array_map( fn ($l) => preg_replace('/^\s*(?:[-•*]|\d+[.)]?)\s+/', '', trim($l)), preg_split('/\r\n|\r|\n/', $text) ), fn ($l) => $l !== '' ) ); // Attach the parsed data directly onto the model as dynamic properties // so the blade's $jobDescription->responsibilities etc. work $jobDescription->role_summary = $roleSummary; $jobDescription->responsibilities = $parseLines($responsibilities); $jobDescription->required_skills = $parseLines($requiredSkills); $jobDescription->qualifications = $parseLines($qualifications); $jobDescription->competencies = $parseLines($competencies); // job_id formatted as JD-001 etc. $jobDescription->job_id = 'JD-' . str_pad($jobDescription->id, 3, '0', STR_PAD_LEFT); return view('dashboard.job-descriptions.show', compact('jobDescription')); } /** * Detail view for a single job description (non-wizard view). */ public function detail($jobDescription): View { $jobDescription = JobDescription::with([ 'sections' => fn ($q) => $q->orderBy('sort_order'), 'creator', 'approver', 'publisher', ])->findOrFail($jobDescription); $this->authorizeAccess($jobDescription); // Strip leading bullet markers (-, •, *, numbered "1." etc.) so the // blade's own bullet/number rendering is used consistently. $parseLines = fn (string $text): array => array_values( array_filter( array_map( fn ($l) => preg_replace('/^\s*(?:[-•*]|\d+[.)]?)\s+/', '', trim($l)), preg_split('/\r\n|\r|\n/', $text) ), fn ($l) => $l !== '' ) ); $jobDescription->job_id = 'JD-' . str_pad($jobDescription->id, 3, '0', STR_PAD_LEFT); $jobDescription->role_summary = $jobDescription->getSection(JobDescriptionSection::TYPE_ROLE_SUMMARY)?->content ?? ''; $jobDescription->responsibilities = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_KEY_RESPONSIBILITIES)?->content ?? ''); $jobDescription->required_skills = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_REQUIRED_SKILLS)?->content ?? ''); $jobDescription->qualifications = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_QUALIFICATIONS)?->content ?? ''); $jobDescription->competencies = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_CORE_COMPETENCIES)?->content ?? ''); $jobDescription->created_by = $jobDescription->creator?->name ?? '—'; $jobDescription->approved_by = $jobDescription->approver?->name ?? '—'; return view('dashboard.job-descriptions.detail', compact('jobDescription')); } /** * Public view for sharing job descriptions without authentication. */ public function publicView($jobId, $slug): View { // job_id format is JD-001 → extract the numeric ID $numericId = ltrim(str_replace('JD-', '', strtoupper($jobId)), '0'); $jobDescription = JobDescription::with([ 'sections' => fn ($q) => $q->orderBy('sort_order'), 'organization', ]) ->where('id', $numericId) ->where('status', JobDescription::STATUS_PUBLISHED) ->firstOrFail(); // Strip leading bullet markers (-, •, *, numbered "1." etc.) so the // blade's own bullet/number rendering is used consistently. $parseLines = fn (string $text): array => array_values( array_filter( array_map( fn ($l) => preg_replace('/^\s*(?:[-•*]|\d+[.)]?)\s+/', '', trim($l)), preg_split('/\r\n|\r|\n/', $text) ), fn ($l) => $l !== '' ) ); $jobDescription->job_id = $jobId; $jobDescription->role_summary = $jobDescription->getSection(JobDescriptionSection::TYPE_ROLE_SUMMARY)?->content ?? ''; $jobDescription->responsibilities = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_KEY_RESPONSIBILITIES)?->content ?? ''); $jobDescription->required_skills = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_REQUIRED_SKILLS)?->content ?? ''); $jobDescription->qualifications = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_QUALIFICATIONS)?->content ?? ''); $jobDescription->competencies = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_CORE_COMPETENCIES)?->content ?? ''); return view('dashboard.job-descriptions.public', compact('jobDescription')); } public function edit($jobDescription): View { $jobDescription = JobDescription::findOrFail($jobDescription); $this->authorizeAccess($jobDescription); $departments = Department::ordered()->pluck('name'); return view('dashboard.job-descriptions.create', compact('jobDescription', 'departments')); } public function update(Request $request, $jobDescription) { $jd = JobDescription::findOrFail($jobDescription); $this->authorizeAccess($jd); if ($jd->isPublished()) { return back()->withErrors(['error' => 'Published job descriptions cannot be edited.']); } // Section update (triggered by Save Changes) if ($request->input('_action') === 'update_section') { $validated = $request->validate([ 'section_type' => ['required', Rule::in(JobDescriptionSection::SECTION_TYPES)], 'content' => 'required|string|min:1', ]); DB::transaction(function () use ($validated, $jd) { $sortOrder = array_search( $validated['section_type'], JobDescriptionSection::SECTION_TYPES ); // Use firstOrNew so we can increment version correctly on both // insert and update paths — DB::raw('version + 1') only works // on UPDATE and throws on INSERT when the column is cast to int. $section = JobDescriptionSection::firstOrNew([ 'job_description_id' => $jd->id, 'section_type' => $validated['section_type'], ]); $section->content = $validated['content']; $section->source = JobDescriptionSection::SOURCE_USER; $section->version = $section->exists ? ($section->version + 1) : 1; $section->is_core = true; $section->sort_order = $sortOrder; $section->save(); JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_SECTION_EDITED, 'meta' => ['section_type' => $validated['section_type']], ]); }); return back()->with('success', 'Section updated.'); } // Metadata update $validated = $request->validate([ 'job_title' => 'required|string|max:255', 'department' => 'required|string|max:255', 'level' => 'required|string|max:255', 'location' => 'required|string|max:255', 'employment_type' => 'required|string|max:100', ]); $jd->update($validated); return redirect()->route('dashboard.job-descriptions.show', $jd->id); } public function destroy($jobDescription) { $jd = JobDescription::findOrFail($jobDescription); $this->authorizeAccess($jd); $jd->delete(); return redirect()->route('dashboard.job-descriptions.index') ->with('success', 'Job description deleted!'); } public function generating($jobDescription): View { $jobDescription = JobDescription::findOrFail($jobDescription); $this->authorizeAccess($jobDescription); return view('dashboard.job-descriptions.generating', compact('jobDescription')); } public function validateStep($jobDescription): View { $jobDescription = JobDescription::with([ 'sections' => fn ($q) => $q->orderBy('sort_order'), 'validationChecks', ])->findOrFail($jobDescription); $this->authorizeAccess($jobDescription); // Run the AI validation and persist results try { $aiResult = $this->aiService->validateJD($jobDescription); DB::transaction(function () use ($aiResult, $jobDescription) { $jobDescription->validationChecks()->delete(); foreach ($aiResult['checks'] as $check) { JobDescriptionValidationCheck::create([ 'job_description_id' => $jobDescription->id, 'section_type' => $check['section_type'] ?? JobDescriptionSection::sectionTypeForCheck($check['check_key']), 'check_key' => $check['check_key'], 'status' => $check['status'], 'score' => $check['score'] ?? 0, 'passed' => $check['passed'], 'message' => $check['message'] ?? null, ]); } JobDescriptionActivityLog::create([ 'job_description_id' => $jobDescription->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_VALIDATION_RUN, 'meta' => null, ]); $overallPassed = $aiResult['passed'] ?? false; JobDescriptionActivityLog::create([ 'job_description_id' => $jobDescription->id, 'user_id' => Auth::id(), 'event' => $overallPassed ? JobDescriptionActivityLog::EVENT_VALIDATION_PASSED : JobDescriptionActivityLog::EVENT_VALIDATION_FAILED, 'meta' => ['score' => $aiResult['score'] ?? null], ]); }); } catch (\Throwable $e) { Log::error('[JobDescriptionController] validateStep AI failed', [ 'id' => $jobDescription->id, 'error' => $e->getMessage(), ]); } // Reload fresh check results from DB $jobDescription->load('validationChecks'); $checks = $jobDescription->validationChecks; $validationStats = [ 'total' => $checks->count(), 'aligned' => $checks->where('passed', true)->count(), 'needs_review' => $checks->where('passed', false)->count(), ]; return view('dashboard.job-descriptions.validate', compact('jobDescription', 'validationStats')); } public function review($jobDescription): View { $jobDescription = JobDescription::with([ 'sections' => fn ($q) => $q->orderBy('sort_order'), 'creator', 'approver', 'activityLog.user', 'validationChecks', ])->findOrFail($jobDescription); $this->authorizeAccess($jobDescription); // Strip leading bullet markers (-, •, *, numbered "1." etc.) so the // blade's own bullet/number rendering is used consistently. $parseLines = fn (string $text): array => array_values( array_filter( array_map( fn ($l) => preg_replace('/^\s*(?:[-•*]|\d+[.)]?)\s+/', '', trim($l)), preg_split('/\r\n|\r|\n/', $text) ), fn ($l) => $l !== '' ) ); $jobDescription->job_id = 'JD-' . str_pad($jobDescription->id, 3, '0', STR_PAD_LEFT); $jobDescription->role_summary = $jobDescription->getSection(JobDescriptionSection::TYPE_ROLE_SUMMARY)?->content ?? ''; $jobDescription->responsibilities = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_KEY_RESPONSIBILITIES)?->content ?? ''); $jobDescription->required_skills = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_REQUIRED_SKILLS)?->content ?? ''); $jobDescription->qualifications = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_QUALIFICATIONS)?->content ?? ''); $jobDescription->competencies = $parseLines($jobDescription->getSection(JobDescriptionSection::TYPE_CORE_COMPETENCIES)?->content ?? ''); return view('dashboard.job-descriptions.review', compact('jobDescription')); } public function publish($id): View { $jobDescription = JobDescription::with(['creator', 'approver'])->findOrFail($id); $this->authorizeAccess($jobDescription); DB::transaction(function () use ($jobDescription) { $jobId = 'JD-' . str_pad($jobDescription->id, 3, '0', STR_PAD_LEFT); $jobDescription->update([ 'status' => JobDescription::STATUS_PUBLISHED, 'published_by' => Auth::id(), 'published_at' => now(), 'published_link_token' => \Illuminate\Support\Str::uuid()->toString(), ]); JobDescriptionActivityLog::create([ 'job_description_id' => $jobDescription->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_PUBLISHED, 'meta' => null, ]); }); return view('dashboard.job-descriptions.publish', compact('jobDescription')); } public function submitApproval(Request $request, $jobDescription) { $jd = JobDescription::findOrFail($jobDescription); $this->authorizeAccess($jd); $validated = $request->validate([ 'comment' => 'nullable|string|max:1000', ]); DB::transaction(function () use ($validated, $jd) { $jd->update([ 'status' => JobDescription::STATUS_APPROVED, 'approved_by' => Auth::id(), 'approved_at' => now(), ]); if (!empty($validated['comment'])) { JobDescriptionComment::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'comment' => $validated['comment'], ]); } JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_APPROVED, 'meta' => null, ]); }); $referer = request()->headers->get('referer', ''); if (str_contains($referer, '/detail')) { return redirect()->route('dashboard.job-descriptions.detail', $jd->id) ->with('success', 'Job description approved successfully.'); } return redirect()->route('dashboard.job-descriptions.review', $jd->id) ->with('success', 'Job description approved successfully.'); } public function saveDraft(Request $request, $jobDescription) { $jd = JobDescription::findOrFail($jobDescription); $this->authorizeAccess($jd); if ($jd->isPublished()) { return back()->withErrors(['error' => 'Published job descriptions cannot be reverted to draft.']); } DB::transaction(function () use ($jd) { $jd->update([ 'status' => JobDescription::STATUS_DRAFT, 'approved_by' => null, 'approved_at' => null, ]); JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_SAVED_AS_DRAFT, 'meta' => null, ]); }); return redirect()->route('dashboard.job-descriptions.index') ->with('success', 'Draft saved!'); } // Publish an approved job description public function doPublish($jobDescription) { $jd = JobDescription::findOrFail($jobDescription); $this->authorizeAccess($jd); if (!$jd->isApproved()) { return back()->withErrors(['error' => 'Only approved job descriptions can be published.']); } DB::transaction(function () use ($jd) { $jd->update([ 'status' => JobDescription::STATUS_PUBLISHED, 'published_by' => Auth::id(), 'published_at' => now(), 'published_link_token' => \Illuminate\Support\Str::uuid()->toString(), ]); JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_PUBLISHED, 'meta' => null, ]); }); // Redirect back to whichever page triggered the publish action. // Uses the HTTP referer so it works from both detail and publish pages. $referer = request()->headers->get('referer', ''); if (str_contains($referer, '/publish')) { return redirect()->route('dashboard.job-descriptions.publish', $jd->id) ->with('success', 'Job description published successfully.'); } return redirect()->route('dashboard.job-descriptions.detail', $jd->id) ->with('success', 'Job description published successfully.'); } // Unpublish a published job description (revert back to approved status) public function unpublish($jobDescription) { $jd = JobDescription::findOrFail($jobDescription); $this->authorizeAccess($jd); if (!$jd->isPublished()) { return back()->withErrors(['error' => 'This job description is not published.']); } DB::transaction(function () use ($jd) { $jd->update([ 'status' => JobDescription::STATUS_APPROVED, 'published_by' => null, 'published_at' => null, 'published_link_token' => null, ]); JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_SAVED_AS_DRAFT, 'meta' => ['action' => 'unpublished'], ]); }); $referer = request()->headers->get('referer', ''); if (str_contains($referer, '/publish')) { return redirect()->route('dashboard.job-descriptions.publish', $jd->id) ->with('success', 'Job description unpublished.'); } return redirect()->route('dashboard.job-descriptions.detail', $jd->id) ->with('success', 'Job description unpublished.'); } public function regenerateSection(Request $request, $jobDescription) { $jd = JobDescription::with('sections')->findOrFail($jobDescription); $this->authorizeAccess($jd); if ($jd->isPublished()) { return back()->withErrors(['error' => 'Published job descriptions cannot be edited.']); } $validated = $request->validate([ 'section_type' => ['required', Rule::in(JobDescriptionSection::SECTION_TYPES)], 'instruction' => 'nullable|string|max:500', ]); try { $newContent = $this->aiService->regenerateSection( $jd, $validated['section_type'], $validated['instruction'] ?? '' ); DB::transaction(function () use ($newContent, $validated, $jd) { $section = JobDescriptionSection::firstOrNew([ 'job_description_id' => $jd->id, 'section_type' => $validated['section_type'], ]); $section->content = $newContent; $section->source = JobDescriptionSection::SOURCE_AI; $section->version = $section->exists ? ($section->version + 1) : 1; $section->is_core = true; $section->sort_order = array_search( $validated['section_type'], JobDescriptionSection::SECTION_TYPES ); $section->save(); JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_SECTION_REGENERATED, 'meta' => ['section_type' => $validated['section_type']], ]); }); } catch (\Throwable $e) { return back()->withErrors(['error' => 'Regeneration failed. Please try again.']); } return back()->with('success', 'Section regenerated!'); } public function export($jobDescription, $format) { $jd = JobDescription::with([ 'sections' => fn ($q) => $q->orderBy('sort_order'), 'creator', 'approver', 'organization', ])->findOrFail($jobDescription); $this->authorizeAccess($jd); if (!$jd->isPublished()) { return back()->withErrors(['error' => 'A job description must be published before it can be exported.']); } $allowed = ['pdf', 'docx']; if (!in_array($format, $allowed)) { return back()->withErrors(['error' => 'Invalid export format.']); } $slug = Str::slug($jd->job_title); $filename = "{$slug}-{$jd->id}.{$format}"; try { if ($format === 'pdf') { return $this->streamPdf($jd, $filename); } return $this->streamDocx($jd, $filename); } catch (\Throwable $e) { Log::error('[JobDescriptionController] export failed', [ 'id' => $jd->id, 'format' => $format, 'error' => $e->getMessage(), ]); return back()->withErrors(['error' => 'Export failed. Please try again.']); } } // Add a custom section public function addCustomSection(Request $request, $jobDescription) { $jd = JobDescription::findOrFail($jobDescription); $this->authorizeAccess($jd); if ($jd->isPublished()) { return back()->withErrors(['error' => 'Published job descriptions cannot be edited.']); } $validated = $request->validate([ 'title' => 'required|string|max:255', 'content' => 'required|string|min:10', ]); $section = null; DB::transaction(function () use ($validated, $jd, &$section) { $maxOrder = $jd->sections()->max('sort_order') ?? 0; $section = JobDescriptionSection::create([ 'job_description_id' => $jd->id, 'section_type' => null, 'title' => $validated['title'], 'content' => $validated['content'], 'source' => JobDescriptionSection::SOURCE_USER, 'version' => 1, 'is_core' => false, 'sort_order' => $maxOrder + 1, ]); JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_CUSTOM_SECTION_ADDED, 'meta' => ['title' => $validated['title']], ]); }); // return back()->with('success', 'Custom section added.'); return response()->json([ 'success' => true, 'section' => $section, 'message' => 'Custom section added successfully.', ]); } // Delete a custom section // public function deleteCustomSection($jobDescription, $section) // { // $jd = JobDescription::findOrFail($jobDescription); // $sec = JobDescriptionSection::findOrFail($section); // $this->authorizeAccess($jd); // if ($jd->isPublished()) { // return back()->withErrors(['error' => 'Published job descriptions cannot be edited.']); // } // if ($sec->isCore()) { // return back()->withErrors(['error' => 'Core sections cannot be deleted.']); // } // $title = $sec->title; // $sec->delete(); // JobDescriptionActivityLog::create([ // 'job_description_id' => $jd->id, // 'user_id' => Auth::id(), // 'event' => JobDescriptionActivityLog::EVENT_CUSTOM_SECTION_DELETED, // 'meta' => ['title' => $title], // ]); // return back()->with('success', 'Custom section deleted.'); // } public function deleteCustomSection($jobDescription, $section) { // Load job description $jd = JobDescription::findOrFail($jobDescription); // Authorization FIRST $this->authorizeAccess($jd); // Block edits if published if ($jd->isPublished()) { return response()->json([ 'success' => false, 'message' => 'Published job descriptions cannot be edited.' ], 403); } // IMPORTANT: scope section to job description (FIX) $sec = JobDescriptionSection::where('job_description_id', $jd->id) ->where('id', $section) ->first(); if (!$sec) { return response()->json([ 'success' => false, 'message' => 'Section not found.' ], 404); } // Prevent deleting core sections if ($sec->isCore()) { return response()->json([ 'success' => false, 'message' => 'Core sections cannot be deleted.' ], 403); } // Store title for logging before delete $title = $sec->title; // Delete section $sec->delete(); // Log activity JobDescriptionActivityLog::create([ 'job_description_id' => $jd->id, 'user_id' => Auth::id(), 'event' => JobDescriptionActivityLog::EVENT_CUSTOM_SECTION_DELETED, 'meta' => [ 'title' => $title ], ]); return response()->json([ 'success' => true, 'message' => 'Custom section deleted successfully.', 'data' => [ 'section_id' => $section ] ]); } /** * Ensure the authenticated user is allowed to access this JD. * Org users can access any JD in their org. */ private function authorizeAccess(JobDescription $jobDescription): void { $user = Auth::user(); if ( $user->organization_id && (int) $jobDescription->organization_id === (int) $user->organization_id ) { return; } if ((int) $jobDescription->created_by !== (int) $user->id) { abort(403, 'You do not have permission to access this job description.'); } } /** * Render the JD as PDF via DomPDF and stream it as a download. */ private function streamPdf(JobDescription $jd, string $filename): Response { $pdf = Pdf::loadView( 'jd-wizard.export-pdf', ['jobDescription' => $jd] ); $pdf->setPaper('A4', 'portrait'); return $pdf->download($filename); } public function viewPdf($jd) { $jobDescription = JobDescription::with(['customSections'])->where('id', $jd)->first(); return view( 'jd-wizard.export-pdf', compact('jobDescription') ); } /** * Build the JD as a DOCX via PhpWord and stream it as a download. */ private function streamDocx(JobDescription $jd, string $filename): StreamedResponse { $phpWord = new PhpWord(); // Document defaults $phpWord->setDefaultFontName('Arial'); $phpWord->setDefaultFontSize(11); $phpWord->getDocInfo() ->setTitle($jd->job_title) ->setCreator($jd->creator?->name ?? 'System') ->setCompany($jd->organization?->name ?? ''); // Style definitions $phpWord->addTitleStyle(1, ['bold' => true, 'size' => 20, 'color' => '1F3864'], ['spaceAfter' => 120]); $phpWord->addTitleStyle(2, ['bold' => true, 'size' => 13, 'color' => '2E5496'], ['spaceBefore' => 240, 'spaceAfter' => 80]); $phpWord->addParagraphStyle('body', ['spaceAfter' => 80, 'lineHeight' => 1.3]); $phpWord->addFontStyle('metaLabel', ['bold' => true, 'size' => 10, 'color' => '666666']); $phpWord->addFontStyle('metaValue', ['size' => 10]); $phpWord->addFontStyle('bodyText', ['size' => 11]); $section = $phpWord->addSection([ 'marginTop' => 1440, 'marginBottom' => 1440, 'marginLeft' => 1440, 'marginRight' => 1440, ]); // Title $section->addTitle($jd->job_title, 1); // Meta block foreach (array_filter([ 'Department' => $jd->department, 'Level' => $jd->level, 'Location' => $jd->location, 'Employment Type' => $jd->employment_type, ]) as $label => $value) { $para = $section->addTextRun(['spaceAfter' => 40]); $para->addText("{$label}: ", 'metaLabel'); $para->addText($value, 'metaValue'); } $section->addTextBreak(1); // Sections foreach ($jd->sections->sortBy('sort_order') as $jdSection) { if (empty(trim($jdSection->content ?? ''))) { continue; } $section->addTitle($jdSection->label(), 2); foreach (preg_split('/\r\n|\r|\n/', trim($jdSection->content)) as $line) { $line = trim($line); if ($line === '') { $section->addTextBreak(1); continue; } // Strip leading bullet markers before writing $clean = preg_replace('/^[-•*]\s+/', '', $line); if (preg_match('/^[-•*]\s+/', $line)) { $section->addListItem( $clean, 0, 'bodyText', ['listType' => \PhpOffice\PhpWord\Style\ListItem::TYPE_BULLET_FILLED] ); } else { $section->addText($clean, 'bodyText', 'body'); } } $section->addTextBreak(1); } // Footer $footer = $section->addFooter(); $footer->addPreserveText( ($jd->organization?->name ?? '') . ' | Page {PAGE} of {NUMPAGES}', ['size' => 9, 'color' => '999999'], ['alignment' => \PhpOffice\PhpWord\SimpleType\Jc::CENTER] ); // Stream to browser $writer = IOFactory::createWriter($phpWord, 'Word2007'); return response()->stream(function () use ($writer) { $writer->save('php://output'); }, 200, [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Cache-Control' => 'no-cache, no-store, must-revalidate', 'Pragma' => 'no-cache', 'Expires' => '0', ]); } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.30 | Generation time: 0 |
proxy
|
phpinfo
|
Settings