feat: expose winter placements to n8n over an api endpoint
This commit is contained in:
1 parent
4ff963d1e2
commit
0bc77b7463
17 files changed
+977
-18
No files matched your search
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\Assignment;
|
||||
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Upload;
|
||||
use App\Enum\PlacementStatus;
|
||||
use App\Model\PlacementExportQueryDto;
|
||||
use App\Repository\AssignmentRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The placements feed n8n reads: one row per place of the houses' guide, course and service-team
|
||||
* assignments overlapping a date range. A row is a place, not a person - re-staffing a place
|
||||
* changes the person on the same platz_id, and an open place has person_id null. A called-off
|
||||
* assignment is left out entirely, as is a place the office struck.
|
||||
*
|
||||
* The path, the query parameters and the field names are the consumer's (German); errors are
|
||||
* English, like every response of the api firewall.
|
||||
*/
|
||||
class ListController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @param list<string> $houseCodes
|
||||
* @param list<int> $jobProfileIds
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly AssignmentRepository $assignmentRepository,
|
||||
private readonly array $houseCodes,
|
||||
private readonly array $jobProfileIds,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/einsaetze', name: 'app_api_assignment_list', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_API_PLACEMENTS')]
|
||||
public function index(
|
||||
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
// Deliberately without a default: the resolver would hand that over unvalidated for a
|
||||
// request without any query string, instead of the 400 naming von and bis.
|
||||
PlacementExportQueryDto $query,
|
||||
): JsonResponse {
|
||||
$rows = [];
|
||||
|
||||
$assignments = $this->assignmentRepository->findForPlacementExport(
|
||||
$query->getFrom(),
|
||||
$query->getTo(),
|
||||
$this->houseCodes,
|
||||
$this->jobProfileIds,
|
||||
);
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
foreach ($assignment->getPlaces() as $slot => $disposition) {
|
||||
$rows[] = $this->row($assignment, $slot, $disposition);
|
||||
}
|
||||
}
|
||||
|
||||
// the range and the count, never the rows - they name people
|
||||
$this->logger->info('Placements exported', [
|
||||
'from' => $query->von,
|
||||
'to' => $query->bis,
|
||||
'assignments' => count($assignments),
|
||||
'places' => count($rows),
|
||||
]);
|
||||
|
||||
return new JsonResponse($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function row(Assignment $assignment, int $slot, ?Disposition $disposition): array
|
||||
{
|
||||
$destination = $assignment->getDestination();
|
||||
$jobProfile = $assignment->getJobProfile();
|
||||
$teamer = $disposition?->getTeamer();
|
||||
$code = (string) $destination->getCode();
|
||||
|
||||
return [
|
||||
'platz_id' => sprintf('%d-%d', $assignment->getId(), $slot),
|
||||
'einsatz_id' => $assignment->getId(),
|
||||
'person_id' => $teamer?->getId(),
|
||||
'vorname' => $teamer?->getFirstName(),
|
||||
'nachname' => $teamer?->getLastName(),
|
||||
'haus' => substr((string) $destination->getHotelCode(), 0, 3),
|
||||
'von' => $destination->getDateFrom()->format('Y-m-d'),
|
||||
'bis' => $destination->getDateTo()->format('Y-m-d'),
|
||||
'jobprofil_id' => $jobProfile->getId(),
|
||||
'jobprofil' => $jobProfile->getName(),
|
||||
'status' => PlacementStatus::fromDisposition($disposition)->value,
|
||||
'unter_26' => $this->isUnder26($disposition, $assignment),
|
||||
'vertrag_liegt_vor' => $this->hasCheckedContract($disposition),
|
||||
// Older codes carry a slash ("DPWBSP/030229"), current ones do not.
|
||||
'reise_code' => '' === $code ? null : str_replace('/', '', $code),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Under 26 on the first day of the assignment.
|
||||
*/
|
||||
private function isUnder26(?Disposition $disposition, Assignment $assignment): ?bool
|
||||
{
|
||||
if (null === $dateOfBirth = $disposition?->getTeamer()?->getDateOfBirth()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $dateOfBirth->modify('+26 years') > $assignment->getDestination()->getDateFrom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Only once the office has checked it - an uploaded contract alone does not count. Null where
|
||||
* there is no contract to have: an open place, or an assignment that skips the formalities.
|
||||
*/
|
||||
private function hasCheckedContract(?Disposition $disposition): ?bool
|
||||
{
|
||||
if (null === $disposition || true === $disposition->isSkipFormalities()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return true === $disposition->getDocumentByType(Upload::TYPE_CONTRACT)?->isConfirmed();
|
||||
}
|
||||
}
|
||||
@@ -481,6 +481,78 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The lowest place number no active placement holds - the one a new placement takes.
|
||||
*/
|
||||
public function getNextFreeSlot(): int
|
||||
{
|
||||
return $this->nextFreeSlotIn(array_flip($this->getHeldSlots()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The places the n8n feed reports, as place number => the active placement filling it, or
|
||||
* null for an open place. A place is a row there, not a person, so a called-off teamer's place
|
||||
* is open again under the same number rather than gone.
|
||||
*
|
||||
* Every held place is listed, topped up with the lowest free numbers to availableDispositions.
|
||||
* Lowering the count while a high number is still held therefore drops an open low number, not
|
||||
* the held one; a held place beyond the count is still listed, it has a person on it.
|
||||
*
|
||||
* @return array<int, Disposition|null>
|
||||
*/
|
||||
public function getPlaces(): array
|
||||
{
|
||||
$places = [];
|
||||
$unnumbered = [];
|
||||
|
||||
foreach ($this->getActiveDispositions() as $disposition) {
|
||||
if (null === $slot = $disposition->getSlot()) {
|
||||
$unnumbered[] = $disposition;
|
||||
} else {
|
||||
$places[$slot] = $disposition;
|
||||
}
|
||||
}
|
||||
|
||||
// Without a number only when created outside DisposeController; such a placement takes
|
||||
// the next free number after the numbered ones, so that nobody drops out of the feed.
|
||||
foreach ($unnumbered as $disposition) {
|
||||
$places[$this->nextFreeSlotIn($places)] = $disposition;
|
||||
}
|
||||
|
||||
while (count($places) < (int) $this->availableDispositions) {
|
||||
$places[$this->nextFreeSlotIn($places)] = null;
|
||||
}
|
||||
|
||||
ksort($places);
|
||||
|
||||
return $places;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function getHeldSlots(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->getActiveDispositions()->map(fn (Disposition $disposition) => $disposition->getSlot())->toArray(),
|
||||
fn (?int $slot) => null !== $slot,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $places
|
||||
*/
|
||||
private function nextFreeSlotIn(array $places): int
|
||||
{
|
||||
$slot = 1;
|
||||
|
||||
while (array_key_exists($slot, $places)) {
|
||||
++$slot;
|
||||
}
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
public function getConfirmedDispositions(): Collection
|
||||
{
|
||||
return $this->dispositions->filter(function (Disposition $disposition) {
|
||||
|
||||
@@ -80,6 +80,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
|
||||
private bool $feedbackWaived = false;
|
||||
|
||||
/**
|
||||
* The numbered place on the assignment this placement fills - the n8n feed's "platz_id" is
|
||||
* "<assignment id>-<slot>", and n8n hangs the Wechselpass off it. Taken once, here at creation,
|
||||
* and never renumbered: a replacement takes the number the called-off teamer freed, whoever
|
||||
* stays keeps theirs. A called-off placement keeps its number for the record but no longer
|
||||
* holds it (Assignment::getPlaces()).
|
||||
*/
|
||||
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||
private ?int $slot = null;
|
||||
|
||||
public function __construct(Application $application)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
@@ -89,6 +99,7 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
? static::STATUS_CONFIRMED
|
||||
: static::STATUS_NEW;
|
||||
$this->assignment = $application->getAssignment();
|
||||
$this->slot = $this->assignment?->getNextFreeSlot();
|
||||
$this->teamer = $application->getTeamer();
|
||||
$this->pickup = $application->getPickup();
|
||||
$this->specialAgreements = $application->getRequests();
|
||||
@@ -373,4 +384,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSlot(): ?int
|
||||
{
|
||||
return $this->slot;
|
||||
}
|
||||
|
||||
public function setSlot(?int $slot): static
|
||||
{
|
||||
$this->slot = $slot;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
|
||||
/**
|
||||
* The status of a place in the n8n placements feed. The values are the feed's contract.
|
||||
*/
|
||||
enum PlacementStatus: string
|
||||
{
|
||||
case OPEN = 'offen';
|
||||
case REQUESTED = 'angefragt';
|
||||
case CONTRACT = 'vertrag';
|
||||
case FIXED = 'fix';
|
||||
|
||||
/**
|
||||
* A called-off placement holds no place, so it never reaches this - its place is open.
|
||||
*/
|
||||
public static function fromDisposition(?Disposition $disposition): self
|
||||
{
|
||||
return match ($disposition?->getStatus()) {
|
||||
null, Disposition::STATUS_CALLED_OFF => self::OPEN,
|
||||
Disposition::STATUS_NEW => self::REQUESTED,
|
||||
Disposition::STATUS_CHECKING_CONTRACT => self::CONTRACT,
|
||||
default => self::FIXED,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* The date range of GET /api/einsaetze, mapped from the query string. The parameter names are
|
||||
* the consumer's (n8n).
|
||||
*/
|
||||
class PlacementExportQueryDto
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\NotBlank(message: 'von must not be empty.')]
|
||||
#[Assert\Date(message: 'von must be a date as YYYY-MM-DD.')]
|
||||
public readonly ?string $von = null,
|
||||
#[Assert\NotBlank(message: 'bis must not be empty.')]
|
||||
#[Assert\Date(message: 'bis must be a date as YYYY-MM-DD.')]
|
||||
#[Assert\GreaterThanOrEqual(propertyPath: 'von', message: 'bis must not be before von.')]
|
||||
public readonly ?string $bis = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFrom(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable($this->von);
|
||||
}
|
||||
|
||||
public function getTo(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable($this->bis);
|
||||
}
|
||||
}
|
||||
@@ -431,4 +431,53 @@ class AssignmentRepository extends ServiceEntityRepository
|
||||
->getSingleColumnResult()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The assignments the n8n placements feed reports: published, in one of the houses, for one of
|
||||
* the job profiles, and overlapping the range. Placements, their teamers and documents come
|
||||
* fetch-joined - every place row needs them. All placements are loaded, called-off ones included:
|
||||
* a fetch join on a filtered collection would leave the assignment with a partial one, and
|
||||
* Assignment::getPlaces() leaves them out anyway.
|
||||
*
|
||||
* @param list<string> $houseCodes matched against the first three characters of the hotel code
|
||||
* @param list<int> $jobProfileIds
|
||||
*
|
||||
* @return list<Assignment>
|
||||
*/
|
||||
public function findForPlacementExport(\DateTimeImmutable $from, \DateTimeImmutable $to, array $houseCodes, array $jobProfileIds): array
|
||||
{
|
||||
if ([] === $houseCodes || [] === $jobProfileIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('assignment');
|
||||
|
||||
return $qb
|
||||
->addSelect('destination', 'jobProfile', 'disposition', 'teamer', 'document')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->innerJoin('assignment.jobProfile', 'jobProfile')
|
||||
->leftJoin('assignment.dispositions', 'disposition')
|
||||
->leftJoin('disposition.teamer', 'teamer')
|
||||
->leftJoin('disposition.documents', 'document')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->lte('destination.dateFrom', ':to'),
|
||||
$qb->expr()->gte('destination.dateTo', ':from'),
|
||||
$qb->expr()->in('SUBSTRING(destination.hotelCode, 1, 3)', ':houseCodes'),
|
||||
$qb->expr()->in('jobProfile.id', ':jobProfileIds'),
|
||||
$qb->expr()->notIn('assignment.status', ':excludedStatuses'),
|
||||
$qb->expr()->isNull('assignment.deletedAt'),
|
||||
$qb->expr()->isNull('destination.deletedAt'),
|
||||
))
|
||||
->orderBy('destination.dateFrom')
|
||||
->addOrderBy('destination.hotelCode')
|
||||
->addOrderBy('assignment.id')
|
||||
->setParameter('from', $from->format('Y-m-d'))
|
||||
->setParameter('to', $to->format('Y-m-d'))
|
||||
->setParameter('houseCodes', $houseCodes)
|
||||
->setParameter('jobProfileIds', $jobProfileIds)
|
||||
->setParameter('excludedStatuses', [Assignment::STATUS_DRAFT, Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -8,38 +8,46 @@ use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
/**
|
||||
* Validates the shared key a sibling website posts with. There is no person to log in and no
|
||||
* account to look up: the key is the identity, and the user it resolves to is the in-memory
|
||||
* "api" user of the api_user_provider - nothing about it may reach the user table, where a row
|
||||
* would be a login to this application's UI.
|
||||
* Validates the shared key a machine caller sends. There is no person to log in and no account to
|
||||
* look up: the key is the identity, and the user it resolves to is one of the in-memory users of
|
||||
* the api_user_provider - nothing about it may reach the user table, where a row would be a login
|
||||
* to this application's UI.
|
||||
*
|
||||
* One key per caller, each resolving to its own user and roles: the websites posting application
|
||||
* forms must not read placements, and n8n reading placements must not post forms.
|
||||
*/
|
||||
class ApiKeyTokenHandler implements AccessTokenHandlerInterface
|
||||
{
|
||||
public const USER_IDENTIFIER = 'api';
|
||||
|
||||
/**
|
||||
* @param array<string, string> $apiKeys user identifier => key
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $apiKey,
|
||||
private readonly array $apiKeys,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getUserBadgeFrom(string $accessToken): UserBadge
|
||||
{
|
||||
// An unconfigured key locks the endpoint instead of opening it: hash_equals('', '')
|
||||
// is true, so without this check a deployment that forgot the secret would accept
|
||||
// an empty key from anyone.
|
||||
if ('' === $this->apiKey) {
|
||||
// An unconfigured key locks its caller out instead of opening the door: hash_equals('', '')
|
||||
// is true, so without this filter a deployment that forgot a secret would accept an empty
|
||||
// key from anyone.
|
||||
$configuredKeys = array_filter($this->apiKeys, fn (string $key) => '' !== $key);
|
||||
|
||||
if ([] === $configuredKeys) {
|
||||
$this->logger->error('API key authentication attempted while no key is configured');
|
||||
|
||||
throw new BadCredentialsException('No API key configured.');
|
||||
}
|
||||
|
||||
if (false === hash_equals($this->apiKey, $accessToken)) {
|
||||
$this->logger->warning('API key authentication failed');
|
||||
|
||||
throw new BadCredentialsException('Invalid API key.');
|
||||
foreach ($configuredKeys as $identifier => $key) {
|
||||
if (true === hash_equals($key, $accessToken)) {
|
||||
return new UserBadge((string) $identifier);
|
||||
}
|
||||
}
|
||||
|
||||
return new UserBadge(self::USER_IDENTIFIER);
|
||||
$this->logger->warning('API key authentication failed');
|
||||
|
||||
throw new BadCredentialsException('Invalid API key.');
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,10 @@ class ApplicationVoter extends Voter
|
||||
return match ($attribute) {
|
||||
static::STATUS, static::DELETE => true,
|
||||
static::VIEW => Application::STATUS_REJECTED !== $status,
|
||||
// A called-off placement frees its place, so it does not count here: re-staffing
|
||||
// must not take raising availableDispositions, which would add a place.
|
||||
static::DISPOSE => Application::STATUS_REJECTED !== $status
|
||||
&& $assignment->getAvailableDispositions() > $assignment->getDispositions()->count(),
|
||||
&& $assignment->getAvailableDispositions() > $assignment->getActiveDispositions()->count(),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user