Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

558 lignes
19KB

  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Controllers;
  4. use App\Models\Job;
  5. use App\Repositories\CampaignRepository;
  6. use App\Repositories\JobAuditRepository;
  7. use App\Repositories\JobRepository;
  8. use App\Repositories\JobTypeRepository;
  9. use App\Services\FileImportService;
  10. use App\Services\GoogleSheetImportService;
  11. use App\ViewModels\JobViewModel;
  12. use Core\Controller;
  13. use Core\Request;
  14. use Core\Response;
  15. use Core\Validator;
  16. class JobController extends Controller
  17. {
  18. public function index(): Response
  19. {
  20. $request = Request::capture();
  21. $model = new JobViewModel();
  22. $model->saved = $request->input('saved') === '1';
  23. $model->deleted = $request->input('deleted') === '1';
  24. return $this->view('jobs.index', [
  25. 'model' => $model,
  26. 'pageTitle' => $model->title,
  27. ]);
  28. }
  29. public function data(): Response
  30. {
  31. return $this->json($this->formatJobRows($this->repo()->allWithDetails()));
  32. }
  33. public function dataForCampaign(string $campaignId): Response
  34. {
  35. return $this->json($this->formatJobRows(
  36. $this->repo()->allWithDetailsForCampaign((int) $campaignId)
  37. ));
  38. }
  39. public function campaign(string $campaignId): Response
  40. {
  41. $campaign = $this->campaignRepo()->findWithType((int) $campaignId);
  42. if ($campaign === null) {
  43. return $this->redirect('/campaigns');
  44. }
  45. return $this->view('jobs.campaign', [
  46. 'campaign' => $campaign,
  47. 'jobTypes' => $this->loadJobTypes(),
  48. 'pageTitle' => 'Campaign #' . $campaignId . ' Jobs',
  49. ]);
  50. }
  51. public function googleSheetsList(string $campaignId): Response
  52. {
  53. if ($this->campaignRepo()->find((int) $campaignId) === null) {
  54. return Response::json(['error' => 'Campaign not found.'], 404);
  55. }
  56. $request = Request::capture();
  57. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  58. return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
  59. }
  60. $url = trim((string) $request->input('sheet_url', ''));
  61. if ($url === '') {
  62. return Response::json(['error' => 'Enter a Google Sheets URL.'], 422);
  63. }
  64. try {
  65. return Response::json($this->googleSheets()->sheets($url));
  66. } catch (\Throwable $e) {
  67. return Response::json(['error' => $e->getMessage()], 422);
  68. }
  69. }
  70. public function importGoogleSheet(string $campaignId): Response
  71. {
  72. if ($this->campaignRepo()->find((int) $campaignId) === null) {
  73. return Response::json(['error' => 'Campaign not found.'], 404);
  74. }
  75. $request = Request::capture();
  76. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  77. return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
  78. }
  79. $url = trim((string) $request->input('sheet_url', ''));
  80. $gid = trim((string) $request->input('sheet_gid', ''));
  81. $jobTypeId = (int) $request->input('job_type_id', 0);
  82. $jobType = $this->jtRepo()->find($jobTypeId);
  83. if ($url === '' || $gid === '' || $jobType === null) {
  84. return Response::json(['error' => 'Select a Google Sheets file, sheet, and job type.'], 422);
  85. }
  86. try {
  87. $sheet = $this->googleSheets()->rows($url, $gid);
  88. $attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? [];
  89. $result = $this->importRows(
  90. (int) $campaignId,
  91. $jobTypeId,
  92. $attributes,
  93. $sheet['headers'],
  94. $sheet['rows']
  95. );
  96. return Response::json($result);
  97. } catch (\Throwable $e) {
  98. return Response::json(['error' => $e->getMessage()], 422);
  99. }
  100. }
  101. public function create(): Response
  102. {
  103. $model = new JobViewModel();
  104. $model->title = 'New Job';
  105. $model->campaigns = $this->loadCampaigns();
  106. $model->jobTypes = $this->loadJobTypes();
  107. return $this->view('jobs.create', [
  108. 'model' => $model,
  109. 'pageTitle' => $model->title,
  110. ]);
  111. }
  112. public function store(): Response
  113. {
  114. $request = Request::capture();
  115. $model = new JobViewModel();
  116. $model->title = 'New Job';
  117. $model->campaigns = $this->loadCampaigns();
  118. $model->jobTypes = $this->loadJobTypes();
  119. [$form, $errors] = $this->validateForm($request, $model->jobTypes);
  120. if (!empty($errors)) {
  121. $model->form = $form;
  122. $model->errors = $errors;
  123. return $this->view('jobs.create', [
  124. 'model' => $model,
  125. 'pageTitle' => $model->title,
  126. ]);
  127. }
  128. $job = new Job();
  129. $job->campaignId = (int) $form['campaign_id'];
  130. $job->jobTypeId = (int) $form['job_type_id'];
  131. $job->attributeValues = $form['attribute_values'];
  132. $this->repo()->create($job);
  133. $inserted = $this->repo()->findLatestByCampaignAndType($job->campaignId, $job->jobTypeId);
  134. if ($inserted !== null) {
  135. $this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername());
  136. }
  137. return $this->redirect('/jobs?saved=1');
  138. }
  139. public function edit(string $id): Response
  140. {
  141. $row = $this->repo()->findWithDetails((int) $id);
  142. if ($row === null) {
  143. return $this->redirect('/jobs');
  144. }
  145. $storedValues = !empty($row['attribute_values'])
  146. ? (json_decode((string) $row['attribute_values'], true) ?? [])
  147. : [];
  148. $model = new JobViewModel();
  149. $model->title = 'Edit Job';
  150. $model->job = $row;
  151. $model->saved = Request::capture()->input('saved') === '1';
  152. $model->campaigns = $this->loadCampaigns();
  153. $model->jobTypes = $this->loadJobTypes();
  154. $model->form = [
  155. 'campaign_id' => (int) $row['campaign_id'],
  156. 'job_type_id' => (int) $row['job_type_id'],
  157. 'attribute_values' => $storedValues,
  158. ];
  159. return $this->view('jobs.edit', [
  160. 'model' => $model,
  161. 'pageTitle' => $model->title,
  162. ]);
  163. }
  164. public function update(string $id): Response
  165. {
  166. $before = $this->repo()->findWithDetails((int) $id);
  167. if ($before === null) {
  168. return $this->redirect('/jobs');
  169. }
  170. $request = Request::capture();
  171. $model = new JobViewModel();
  172. $model->title = 'Edit Job';
  173. $model->job = $before;
  174. $model->campaigns = $this->loadCampaigns();
  175. $model->jobTypes = $this->loadJobTypes();
  176. [$form, $errors] = $this->validateForm($request, $model->jobTypes);
  177. if (!empty($errors)) {
  178. $model->form = $form;
  179. $model->errors = $errors;
  180. return $this->view('jobs.edit', [
  181. 'model' => $model,
  182. 'pageTitle' => $model->title,
  183. ]);
  184. }
  185. $job = new Job();
  186. $job->id = (int) $id;
  187. $job->campaignId = (int) $form['campaign_id'];
  188. $job->jobTypeId = (int) $form['job_type_id'];
  189. $job->attributeValues = $form['attribute_values'];
  190. $this->repo()->update($job);
  191. $after = $this->repo()->findWithDetails((int) $id);
  192. $this->auditRepo()->log((int) $id, 'U', [
  193. 'before' => $this->toAuditFields($before),
  194. 'after' => $this->toAuditFields($after ?? []),
  195. ], $this->currentUsername());
  196. return $this->redirect('/jobs/' . $id . '/edit?saved=1');
  197. }
  198. public function destroy(string $id): Response
  199. {
  200. $row = $this->repo()->findWithDetails((int) $id);
  201. if ($row !== null) {
  202. $this->repo()->delete((int) $id);
  203. $this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername());
  204. }
  205. return $this->redirect('/jobs?deleted=1');
  206. }
  207. // ── Helpers ───────────────────────────────────────────────────────────────
  208. private function loadCampaigns(): array
  209. {
  210. return $this->campaignRepo()->allWithType();
  211. }
  212. private function loadJobTypes(): array
  213. {
  214. return array_map(static function (array $t): array {
  215. return [
  216. 'id' => (int) $t['id'],
  217. 'name' => (string) $t['name'],
  218. 'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [],
  219. ];
  220. }, $this->jtRepo()->allOrderedByName());
  221. }
  222. private function attributesForType(int $typeId, array $types): array
  223. {
  224. foreach ($types as $type) {
  225. if ($type['id'] === $typeId) return $type['attributes'];
  226. }
  227. return [];
  228. }
  229. /**
  230. * @param list<array{name?: string, type?: string, order?: int}> $attributes
  231. * @param list<string> $headers
  232. * @param list<array<string, string>> $rows
  233. * @return array{imported: int, skipped: int, matched_attributes: list<string>}
  234. */
  235. private function importRows(int $campaignId, int $jobTypeId, array $attributes, array $headers, array $rows): array
  236. {
  237. $headersByName = [];
  238. foreach ($headers as $header) {
  239. $normalized = $this->normalizeImportHeader($header);
  240. if ($normalized !== '') {
  241. $headersByName[$normalized] = $header;
  242. }
  243. }
  244. $matchedAttributes = [];
  245. foreach ($attributes as $attribute) {
  246. $name = trim((string) ($attribute['name'] ?? ''));
  247. if ($name === '') {
  248. continue;
  249. }
  250. $header = $headersByName[$this->normalizeImportHeader($name)] ?? null;
  251. if ($header !== null) {
  252. $matchedAttributes[$name] = $header;
  253. }
  254. }
  255. if ($matchedAttributes === []) {
  256. throw new \RuntimeException('No sheet headers matched the selected job type attributes.');
  257. }
  258. $imported = 0;
  259. $skipped = 0;
  260. foreach ($rows as $row) {
  261. $attributeValues = [];
  262. $hasValue = false;
  263. foreach ($matchedAttributes as $attributeName => $header) {
  264. $value = trim((string) ($row[$header] ?? ''));
  265. $attributeValues[$attributeName] = $value;
  266. $hasValue = $hasValue || $value !== '';
  267. }
  268. if (!$hasValue) {
  269. $skipped++;
  270. continue;
  271. }
  272. $job = new Job();
  273. $job->campaignId = $campaignId;
  274. $job->jobTypeId = $jobTypeId;
  275. $job->attributeValues = $attributeValues;
  276. $this->repo()->create($job);
  277. $inserted = $this->repo()->findLatestByCampaignAndType($campaignId, $jobTypeId);
  278. if ($inserted !== null) {
  279. $this->auditRepo()->log(
  280. (int) $inserted['id'],
  281. 'I',
  282. $this->toAuditFields($inserted),
  283. $this->currentUsername()
  284. );
  285. }
  286. $imported++;
  287. }
  288. return [
  289. 'imported' => $imported,
  290. 'skipped' => $skipped,
  291. 'matched_attributes' => array_keys($matchedAttributes),
  292. ];
  293. }
  294. private function normalizeImportHeader(string $value): string
  295. {
  296. $value = strtolower(trim($value));
  297. $value = preg_replace('/[^a-z0-9]+/', ' ', $value) ?? '';
  298. return trim(preg_replace('/\s+/', ' ', $value) ?? '');
  299. }
  300. private function googleSheets(): GoogleSheetImportService
  301. {
  302. return new GoogleSheetImportService();
  303. }
  304. private function fileImport(): FileImportService
  305. {
  306. return new FileImportService();
  307. }
  308. // ── File upload import ────────────────────────────────────────────────────
  309. public function fileSheetsList(string $campaignId): Response
  310. {
  311. if ($this->campaignRepo()->find((int) $campaignId) === null) {
  312. return Response::json(['error' => 'Campaign not found.'], 404);
  313. }
  314. $request = Request::capture();
  315. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  316. return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
  317. }
  318. $upload = $_FILES['import_file'] ?? null;
  319. if ($upload === null || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
  320. return Response::json(['error' => 'No file was uploaded.'], 422);
  321. }
  322. try {
  323. $service = $this->fileImport();
  324. $filename = $service->store($upload);
  325. $sheets = $service->sheets($filename);
  326. return Response::json(['temp_file' => $filename, 'sheets' => $sheets]);
  327. } catch (\Throwable $e) {
  328. return Response::json(['error' => $e->getMessage()], 422);
  329. }
  330. }
  331. public function importFile(string $campaignId): Response
  332. {
  333. if ($this->campaignRepo()->find((int) $campaignId) === null) {
  334. return Response::json(['error' => 'Campaign not found.'], 404);
  335. }
  336. $request = Request::capture();
  337. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  338. return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
  339. }
  340. $tempFile = basename(trim((string) $request->input('temp_file', '')));
  341. $gid = trim((string) $request->input('sheet_gid', '0'));
  342. $jobTypeId = (int) $request->input('job_type_id', 0);
  343. $jobType = $this->jtRepo()->find($jobTypeId);
  344. if ($tempFile === '' || $jobType === null) {
  345. return Response::json(['error' => 'Select a file, sheet, and job type.'], 422);
  346. }
  347. try {
  348. $service = $this->fileImport();
  349. $sheet = $service->rows($tempFile, $gid);
  350. $attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? [];
  351. $result = $this->importRows(
  352. (int) $campaignId,
  353. $jobTypeId,
  354. $attributes,
  355. $sheet['headers'],
  356. $sheet['rows']
  357. );
  358. $service->delete($tempFile);
  359. return Response::json($result);
  360. } catch (\Throwable $e) {
  361. return Response::json(['error' => $e->getMessage()], 422);
  362. }
  363. }
  364. /**
  365. * @param list<array<string, mixed>> $rows
  366. * @return list<array<string, mixed>>
  367. */
  368. private function formatJobRows(array $rows): array
  369. {
  370. return array_map(static function (array $row): array {
  371. $attrValues = !empty($row['attribute_values'])
  372. ? (json_decode((string) $row['attribute_values'], true) ?? [])
  373. : [];
  374. $jobTypeAttributes = !empty($row['job_type_attributes'])
  375. ? (json_decode((string) $row['job_type_attributes'], true) ?? [])
  376. : [];
  377. $summary = implode(', ', array_map(
  378. static fn($k, $v) => "{$k}: {$v}",
  379. array_keys($attrValues),
  380. array_values($attrValues)
  381. ));
  382. return [
  383. 'id' => (int) $row['id'],
  384. 'campaign_id' => (int) $row['campaign_id'],
  385. 'campaign_type_name' => (string) $row['campaign_type_name'],
  386. 'job_type_id' => (int) $row['job_type_id'],
  387. 'job_type_name' => (string) $row['job_type_name'],
  388. 'job_type_attributes' => $jobTypeAttributes,
  389. 'attribute_values' => $attrValues,
  390. 'attributes_summary' => $summary,
  391. 'created_at' => (string) $row['created_at'],
  392. 'updated_at' => (string) ($row['updated_at'] ?? ''),
  393. ];
  394. }, $rows);
  395. }
  396. private function validateForm(Request $request, array $jobTypes): array
  397. {
  398. $campaignId = (int) $request->input('campaign_id', 0);
  399. $jobTypeId = (int) $request->input('job_type_id', 0);
  400. $submittedValues = (array) ($request->input('attribute_values') ?? []);
  401. $errors = [];
  402. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  403. $errors['_token'][] = 'Your form session expired. Please refresh and try again.';
  404. }
  405. if ($campaignId === 0) {
  406. $errors['campaign_id'][] = 'Please select a campaign.';
  407. }
  408. if ($jobTypeId === 0) {
  409. $errors['job_type_id'][] = 'Please select a job type.';
  410. }
  411. $typeAttributes = $this->attributesForType($jobTypeId, $jobTypes);
  412. $attributeValues = [];
  413. foreach ($typeAttributes as $attr) {
  414. $attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? ''));
  415. }
  416. return [
  417. ['campaign_id' => $campaignId, 'job_type_id' => $jobTypeId, 'attribute_values' => $attributeValues],
  418. $errors,
  419. ];
  420. }
  421. private function toAuditFields(array $row): array
  422. {
  423. $attrValues = [];
  424. if (!empty($row['attribute_values'])) {
  425. $raw = $row['attribute_values'];
  426. $attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw;
  427. }
  428. return [
  429. 'campaign_id' => (int) ($row['campaign_id'] ?? 0),
  430. 'campaign_type_name' => (string) ($row['campaign_type_name'] ?? ''),
  431. 'job_type_id' => (int) ($row['job_type_id'] ?? 0),
  432. 'job_type_name' => (string) ($row['job_type_name'] ?? ''),
  433. 'attribute_values' => $attrValues,
  434. 'created_at' => (string) ($row['created_at'] ?? ''),
  435. 'updated_at' => (string) ($row['updated_at'] ?? ''),
  436. ];
  437. }
  438. private function currentUsername(): string
  439. {
  440. return auth()->user()?->username ?? 'system';
  441. }
  442. private function repo(): JobRepository
  443. {
  444. return new JobRepository(database());
  445. }
  446. private function auditRepo(): JobAuditRepository
  447. {
  448. return new JobAuditRepository(database());
  449. }
  450. private function campaignRepo(): CampaignRepository
  451. {
  452. return new CampaignRepository(database());
  453. }
  454. private function jtRepo(): JobTypeRepository
  455. {
  456. return new JobTypeRepository(database());
  457. }
  458. }

Powered by TurnKey Linux.