81 lines
3.6 KiB
PHP
81 lines
3.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Opportunity;
|
|
use Carbon\CarbonImmutable;
|
|
|
|
/** Read-time exclusion never depends on the scheduler or the NDA. */
|
|
final class OfferAvailability
|
|
{
|
|
/** @return array{state:string,expires_at:?string,policy_pending:bool} */
|
|
public function view(mixed $operation, mixed $publication): array
|
|
{
|
|
// Legacy public snapshots without an editorial hash retain their first frozen policy.
|
|
$key = is_array($publication) ? ($publication['editorial_hash'] ?? '') : null;
|
|
$key ??= is_array($operation) ? ($operation['last_approval_hash'] ?? '') : '';
|
|
if (! is_string($key)) {
|
|
return ['state' => 'unavailable', 'expires_at' => null, 'policy_pending' => true];
|
|
}
|
|
$policy = is_array($operation) ? ($operation['validity_policies'][$key] ?? $operation['validity'] ?? null) : null;
|
|
$pending = ! is_array($policy) || ($policy['days'] ?? null) === null;
|
|
$result = ['state' => 'available', 'expires_at' => null, 'policy_pending' => $pending];
|
|
if ($operation !== null && ! is_array($operation)) {
|
|
return [...$result, 'state' => 'unavailable'];
|
|
}
|
|
$state = $operation['status'] ?? 'available';
|
|
if (! is_int($operation['cycle'] ?? 0) || ($operation['cycle'] ?? 0) < 0) {
|
|
return [...$result, 'state' => 'unavailable'];
|
|
}
|
|
if (! in_array($state, ['available', 'withdrawn', 'expired', 'revalidation'], true)) {
|
|
return [...$result, 'state' => 'unavailable'];
|
|
}
|
|
if ($state !== 'available') {
|
|
return [...$result, 'state' => $state];
|
|
}
|
|
$days = $policy['days'] ?? null;
|
|
if (($operation['cycle'] ?? 0) > 0 && ($publication['offer_cycle'] ?? null) !== $operation['cycle']) {
|
|
return $result;
|
|
}
|
|
if ($days === null || ! is_array($publication) || ($publication['publication']['state'] ?? null) !== 'published') {
|
|
return $result;
|
|
}
|
|
if (! is_int($days) || $days < 1 || $days > 36500) {
|
|
return [...$result, 'state' => 'unavailable'];
|
|
}
|
|
try {
|
|
$at = $publication['publication']['at'] ?? null;
|
|
if (! is_string($at) || ! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D', $at)) {
|
|
return [...$result, 'state' => 'unavailable'];
|
|
}
|
|
$start = CarbonImmutable::parse($at);
|
|
$end = $start->addDays($days)->utc();
|
|
} catch (\Throwable) {
|
|
return [...$result, 'state' => 'unavailable'];
|
|
}
|
|
|
|
return [...$result, 'expires_at' => $end->toIso8601String(), 'state' => $end->lte(CarbonImmutable::now()) ? 'expired' : 'available'];
|
|
}
|
|
|
|
/** @return array{state:string,expires_at:?string,policy_pending:bool} */
|
|
public function record(Opportunity $record): array
|
|
{
|
|
return $this->view($record->workflow_context['offer_operation'] ?? null, $record->workflow_context['offer_publication'] ?? null);
|
|
}
|
|
|
|
public function publicAllowed(mixed $operation, mixed $publication): bool
|
|
{
|
|
if ($this->view($operation, $publication)['state'] !== 'available') {
|
|
return false;
|
|
}
|
|
$cycle = $operation['cycle'] ?? 0;
|
|
if (! is_int($cycle) || $cycle < 0) {
|
|
return false;
|
|
}
|
|
|
|
// A new validation must never resurrect the previous public snapshot.
|
|
return $cycle === 0 || (is_array($publication) && ($publication['offer_cycle'] ?? null) === $cycle
|
|
&& is_string($operation['activated_hash'] ?? null) && ($publication['editorial_hash'] ?? null) === $operation['activated_hash']);
|
|
}
|
|
}
|