|
- <?php
-
- declare(strict_types=1);
-
- namespace App\Controllers;
-
- use App\Models\Job;
- use App\Repositories\CampaignRepository;
- use App\Repositories\JobAuditRepository;
- use App\Repositories\JobRepository;
- use App\Repositories\JobTypeRepository;
- use App\Services\FileImportService;
- use App\Services\GoogleSheetImportService;
- use App\ViewModels\JobViewModel;
- use Core\Controller;
- use Core\Request;
- use Core\Response;
- use Core\Validator;
-
- class JobController extends Controller
- {
- public function index(): Response
- {
- $request = Request::capture();
- $model = new JobViewModel();
- $model->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<array{name?: string, type?: string, order?: int}> $attributes
- * @param list<string> $headers
- * @param list<array<string, string>> $rows
- * @return array{imported: int, skipped: int, matched_attributes: list<string>}
- */
- 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<array<string, mixed>> $rows
- * @return list<array<string, mixed>>
- */
- 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());
- }
- }
|