saved = $request->input('saved') === '1'; $model->deleted = $request->input('deleted') === '1'; return $this->view('jobs.index', [ 'model' => $model, 'pageTitle' => $model->title, ]); } public function data(): Response { return $this->json($this->formatJobRows($this->repo()->allWithDetails())); } public function dataForCampaign(string $campaignId): Response { return $this->json($this->formatJobRows( $this->repo()->allWithDetailsForCampaign((int) $campaignId) )); } public function campaign(string $campaignId): Response { $campaign = $this->campaignRepo()->findWithType((int) $campaignId); if ($campaign === null) { return $this->redirect('/campaigns'); } return $this->view('jobs.campaign', [ 'campaign' => $campaign, 'jobTypes' => $this->loadJobTypes(), 'pageTitle' => 'Campaign #' . $campaignId . ' Jobs', ]); } public function googleSheetsList(string $campaignId): Response { if ($this->campaignRepo()->find((int) $campaignId) === null) { return Response::json(['error' => 'Campaign not found.'], 404); } $request = Request::capture(); if (!verify_csrf_token((string) $request->input('_token', ''))) { return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419); } $url = trim((string) $request->input('sheet_url', '')); if ($url === '') { return Response::json(['error' => 'Enter a Google Sheets URL.'], 422); } try { return Response::json($this->googleSheets()->sheets($url)); } catch (\Throwable $e) { return Response::json(['error' => $e->getMessage()], 422); } } public function importGoogleSheet(string $campaignId): Response { if ($this->campaignRepo()->find((int) $campaignId) === null) { return Response::json(['error' => 'Campaign not found.'], 404); } $request = Request::capture(); if (!verify_csrf_token((string) $request->input('_token', ''))) { return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419); } $url = trim((string) $request->input('sheet_url', '')); $gid = trim((string) $request->input('sheet_gid', '')); $jobTypeId = (int) $request->input('job_type_id', 0); $jobType = $this->jtRepo()->find($jobTypeId); if ($url === '' || $gid === '' || $jobType === null) { return Response::json(['error' => 'Select a Google Sheets file, sheet, and job type.'], 422); } try { $sheet = $this->googleSheets()->rows($url, $gid); $attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? []; $result = $this->importRows( (int) $campaignId, $jobTypeId, $attributes, $sheet['headers'], $sheet['rows'] ); return Response::json($result); } catch (\Throwable $e) { return Response::json(['error' => $e->getMessage()], 422); } } public function create(): Response { $model = new JobViewModel(); $model->title = 'New Job'; $model->campaigns = $this->loadCampaigns(); $model->jobTypes = $this->loadJobTypes(); return $this->view('jobs.create', [ 'model' => $model, 'pageTitle' => $model->title, ]); } public function store(): Response { $request = Request::capture(); $model = new JobViewModel(); $model->title = 'New Job'; $model->campaigns = $this->loadCampaigns(); $model->jobTypes = $this->loadJobTypes(); [$form, $errors] = $this->validateForm($request, $model->jobTypes); if (!empty($errors)) { $model->form = $form; $model->errors = $errors; return $this->view('jobs.create', [ 'model' => $model, 'pageTitle' => $model->title, ]); } $job = new Job(); $job->campaignId = (int) $form['campaign_id']; $job->jobTypeId = (int) $form['job_type_id']; $job->attributeValues = $form['attribute_values']; $this->repo()->create($job); $inserted = $this->repo()->findLatestByCampaignAndType($job->campaignId, $job->jobTypeId); if ($inserted !== null) { $this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername()); } return $this->redirect('/jobs?saved=1'); } public function edit(string $id): Response { $row = $this->repo()->findWithDetails((int) $id); if ($row === null) { return $this->redirect('/jobs'); } $storedValues = !empty($row['attribute_values']) ? (json_decode((string) $row['attribute_values'], true) ?? []) : []; $model = new JobViewModel(); $model->title = 'Edit Job'; $model->job = $row; $model->saved = Request::capture()->input('saved') === '1'; $model->campaigns = $this->loadCampaigns(); $model->jobTypes = $this->loadJobTypes(); $model->form = [ 'campaign_id' => (int) $row['campaign_id'], 'job_type_id' => (int) $row['job_type_id'], 'attribute_values' => $storedValues, ]; return $this->view('jobs.edit', [ 'model' => $model, 'pageTitle' => $model->title, ]); } public function update(string $id): Response { $before = $this->repo()->findWithDetails((int) $id); if ($before === null) { return $this->redirect('/jobs'); } $request = Request::capture(); $model = new JobViewModel(); $model->title = 'Edit Job'; $model->job = $before; $model->campaigns = $this->loadCampaigns(); $model->jobTypes = $this->loadJobTypes(); [$form, $errors] = $this->validateForm($request, $model->jobTypes); if (!empty($errors)) { $model->form = $form; $model->errors = $errors; return $this->view('jobs.edit', [ 'model' => $model, 'pageTitle' => $model->title, ]); } $job = new Job(); $job->id = (int) $id; $job->campaignId = (int) $form['campaign_id']; $job->jobTypeId = (int) $form['job_type_id']; $job->attributeValues = $form['attribute_values']; $this->repo()->update($job); $after = $this->repo()->findWithDetails((int) $id); $this->auditRepo()->log((int) $id, 'U', [ 'before' => $this->toAuditFields($before), 'after' => $this->toAuditFields($after ?? []), ], $this->currentUsername()); return $this->redirect('/jobs/' . $id . '/edit?saved=1'); } public function destroy(string $id): Response { $row = $this->repo()->findWithDetails((int) $id); if ($row !== null) { $this->repo()->delete((int) $id); $this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername()); } return $this->redirect('/jobs?deleted=1'); } // ── Helpers ─────────────────────────────────────────────────────────────── private function loadCampaigns(): array { return $this->campaignRepo()->allWithType(); } private function loadJobTypes(): array { return array_map(static function (array $t): array { return [ 'id' => (int) $t['id'], 'name' => (string) $t['name'], 'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [], ]; }, $this->jtRepo()->allOrderedByName()); } private function attributesForType(int $typeId, array $types): array { foreach ($types as $type) { if ($type['id'] === $typeId) return $type['attributes']; } return []; } /** * @param list $attributes * @param list $headers * @param list> $rows * @return array{imported: int, skipped: int, matched_attributes: list} */ private function importRows(int $campaignId, int $jobTypeId, array $attributes, array $headers, array $rows): array { $headersByName = []; foreach ($headers as $header) { $normalized = $this->normalizeImportHeader($header); if ($normalized !== '') { $headersByName[$normalized] = $header; } } $matchedAttributes = []; foreach ($attributes as $attribute) { $name = trim((string) ($attribute['name'] ?? '')); if ($name === '') { continue; } $header = $headersByName[$this->normalizeImportHeader($name)] ?? null; if ($header !== null) { $matchedAttributes[$name] = $header; } } if ($matchedAttributes === []) { throw new \RuntimeException('No sheet headers matched the selected job type attributes.'); } $imported = 0; $skipped = 0; foreach ($rows as $row) { $attributeValues = []; $hasValue = false; foreach ($matchedAttributes as $attributeName => $header) { $value = trim((string) ($row[$header] ?? '')); $attributeValues[$attributeName] = $value; $hasValue = $hasValue || $value !== ''; } if (!$hasValue) { $skipped++; continue; } $job = new Job(); $job->campaignId = $campaignId; $job->jobTypeId = $jobTypeId; $job->attributeValues = $attributeValues; $this->repo()->create($job); $inserted = $this->repo()->findLatestByCampaignAndType($campaignId, $jobTypeId); if ($inserted !== null) { $this->auditRepo()->log( (int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername() ); } $imported++; } return [ 'imported' => $imported, 'skipped' => $skipped, 'matched_attributes' => array_keys($matchedAttributes), ]; } private function normalizeImportHeader(string $value): string { $value = strtolower(trim($value)); $value = preg_replace('/[^a-z0-9]+/', ' ', $value) ?? ''; return trim(preg_replace('/\s+/', ' ', $value) ?? ''); } private function googleSheets(): GoogleSheetImportService { return new GoogleSheetImportService(); } private function fileImport(): FileImportService { return new FileImportService(); } // ── File upload import ──────────────────────────────────────────────────── public function fileSheetsList(string $campaignId): Response { if ($this->campaignRepo()->find((int) $campaignId) === null) { return Response::json(['error' => 'Campaign not found.'], 404); } $request = Request::capture(); if (!verify_csrf_token((string) $request->input('_token', ''))) { return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419); } $upload = $_FILES['import_file'] ?? null; if ($upload === null || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { return Response::json(['error' => 'No file was uploaded.'], 422); } try { $service = $this->fileImport(); $filename = $service->store($upload); $sheets = $service->sheets($filename); return Response::json(['temp_file' => $filename, 'sheets' => $sheets]); } catch (\Throwable $e) { return Response::json(['error' => $e->getMessage()], 422); } } public function importFile(string $campaignId): Response { if ($this->campaignRepo()->find((int) $campaignId) === null) { return Response::json(['error' => 'Campaign not found.'], 404); } $request = Request::capture(); if (!verify_csrf_token((string) $request->input('_token', ''))) { return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419); } $tempFile = basename(trim((string) $request->input('temp_file', ''))); $gid = trim((string) $request->input('sheet_gid', '0')); $jobTypeId = (int) $request->input('job_type_id', 0); $jobType = $this->jtRepo()->find($jobTypeId); if ($tempFile === '' || $jobType === null) { return Response::json(['error' => 'Select a file, sheet, and job type.'], 422); } try { $service = $this->fileImport(); $sheet = $service->rows($tempFile, $gid); $attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? []; $result = $this->importRows( (int) $campaignId, $jobTypeId, $attributes, $sheet['headers'], $sheet['rows'] ); $service->delete($tempFile); return Response::json($result); } catch (\Throwable $e) { return Response::json(['error' => $e->getMessage()], 422); } } /** * @param list> $rows * @return list> */ private function formatJobRows(array $rows): array { return array_map(static function (array $row): array { $attrValues = !empty($row['attribute_values']) ? (json_decode((string) $row['attribute_values'], true) ?? []) : []; $jobTypeAttributes = !empty($row['job_type_attributes']) ? (json_decode((string) $row['job_type_attributes'], true) ?? []) : []; $summary = implode(', ', array_map( static fn($k, $v) => "{$k}: {$v}", array_keys($attrValues), array_values($attrValues) )); return [ 'id' => (int) $row['id'], 'campaign_id' => (int) $row['campaign_id'], 'campaign_type_name' => (string) $row['campaign_type_name'], 'job_type_id' => (int) $row['job_type_id'], 'job_type_name' => (string) $row['job_type_name'], 'job_type_attributes' => $jobTypeAttributes, 'attribute_values' => $attrValues, 'attributes_summary' => $summary, 'created_at' => (string) $row['created_at'], 'updated_at' => (string) ($row['updated_at'] ?? ''), ]; }, $rows); } private function validateForm(Request $request, array $jobTypes): array { $campaignId = (int) $request->input('campaign_id', 0); $jobTypeId = (int) $request->input('job_type_id', 0); $submittedValues = (array) ($request->input('attribute_values') ?? []); $errors = []; if (!verify_csrf_token((string) $request->input('_token', ''))) { $errors['_token'][] = 'Your form session expired. Please refresh and try again.'; } if ($campaignId === 0) { $errors['campaign_id'][] = 'Please select a campaign.'; } if ($jobTypeId === 0) { $errors['job_type_id'][] = 'Please select a job type.'; } $typeAttributes = $this->attributesForType($jobTypeId, $jobTypes); $attributeValues = []; foreach ($typeAttributes as $attr) { $attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? '')); } return [ ['campaign_id' => $campaignId, 'job_type_id' => $jobTypeId, 'attribute_values' => $attributeValues], $errors, ]; } private function toAuditFields(array $row): array { $attrValues = []; if (!empty($row['attribute_values'])) { $raw = $row['attribute_values']; $attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw; } return [ 'campaign_id' => (int) ($row['campaign_id'] ?? 0), 'campaign_type_name' => (string) ($row['campaign_type_name'] ?? ''), 'job_type_id' => (int) ($row['job_type_id'] ?? 0), 'job_type_name' => (string) ($row['job_type_name'] ?? ''), 'attribute_values' => $attrValues, 'created_at' => (string) ($row['created_at'] ?? ''), 'updated_at' => (string) ($row['updated_at'] ?? ''), ]; } private function currentUsername(): string { return auth()->user()?->username ?? 'system'; } private function repo(): JobRepository { return new JobRepository(database()); } private function auditRepo(): JobAuditRepository { return new JobAuditRepository(database()); } private function campaignRepo(): CampaignRepository { return new CampaignRepository(database()); } private function jtRepo(): JobTypeRepository { return new JobTypeRepository(database()); } }