refactor: use english naming for the n8n assignments api
This commit is contained in:
1 parent
0bc77b7463
commit
9583612fdc
10 files changed
+84
-81
No files matched your search
@@ -94,7 +94,7 @@ MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
|
||||
MYEP_OAUTH2_SCOPES=email,id,roles,profile
|
||||
# Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage.
|
||||
APPLICATION_FORM_API_KEY=
|
||||
# Shared key n8n reads the placements feed with, in the X-Api-Key header. Empty here: set it per stage.
|
||||
# Shared key n8n reads the assignments feed with, in the X-Api-Key header. Empty here: set it per stage.
|
||||
N8N_API_KEY=
|
||||
# Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form
|
||||
# receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all -
|
||||
|
||||
@@ -13,7 +13,7 @@ security:
|
||||
memory:
|
||||
users:
|
||||
api: { roles: [ ROLE_API ] }
|
||||
n8n: { roles: [ ROLE_API_PLACEMENTS ] }
|
||||
n8n: { roles: [ ROLE_API_ASSIGNMENTS ] }
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
|
||||
|
||||
@@ -40,14 +40,14 @@ parameters:
|
||||
# rejects every request instead of letting them through.
|
||||
application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%'
|
||||
|
||||
# Shared key n8n reads the placements feed with (GET /api/einsaetze). Same rules as above.
|
||||
# Shared key n8n reads the assignments feed with (GET /api/assignments). Same rules as above.
|
||||
n8n_api_key: '%env(string:N8N_API_KEY)%'
|
||||
|
||||
# What the n8n placements feed covers: assignments in these houses (matched against the first
|
||||
# What the n8n assignments feed covers: assignments in these houses (matched against the first
|
||||
# three characters of destination.hotelCode) for these job profiles - guide, course and
|
||||
# service-team work. Job profile ids are database ids, so check them against production.
|
||||
placement_export.house_codes: [ DPW, DGS, DWW, DKI, DKS ]
|
||||
placement_export.job_profile_ids:
|
||||
assignment_export.house_codes: [ DPW, DGS, DWW, DKI, DKS ]
|
||||
assignment_export.job_profile_ids:
|
||||
- 5 # Reiseleitung
|
||||
- 9 # Reiseleitung Ski
|
||||
- 10 # Reiseleitung Snowboard
|
||||
@@ -481,8 +481,8 @@ services:
|
||||
|
||||
App\Controller\Api\Assignment\ListController:
|
||||
arguments:
|
||||
$houseCodes: '%placement_export.house_codes%'
|
||||
$jobProfileIds: '%placement_export.job_profile_ids%'
|
||||
$houseCodes: '%assignment_export.house_codes%'
|
||||
$jobProfileIds: '%assignment_export.job_profile_ids%'
|
||||
$logger: '@monolog.logger.api'
|
||||
tags: [ 'controller.service_arguments' ]
|
||||
|
||||
@@ -514,7 +514,7 @@ when@dev:
|
||||
when@test:
|
||||
parameters:
|
||||
# a job profile id ListControllerTest inserts itself - db_test has no job profiles
|
||||
placement_export.job_profile_ids: [ 900001 ]
|
||||
assignment_export.job_profile_ids: [ 900001 ]
|
||||
mailing_bus_transport: 'sync'
|
||||
# the questionnaire tests need it offered; any day before the placements they create
|
||||
questionnaire_go_live: '2026-01-01'
|
||||
@@ -5,8 +5,8 @@ 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\Enum\PlaceStatus;
|
||||
use App\Model\AssignmentExportQueryDto;
|
||||
use App\Repository\AssignmentRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -17,13 +17,14 @@ 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
|
||||
* The assignments 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.
|
||||
* The field names are the consumer's (German), the n8n workflow is built on them; the path and the
|
||||
* query parameters follow the app's English URL vocabulary, and errors are English, like every
|
||||
* response of the api firewall.
|
||||
*/
|
||||
class ListController extends AbstractController
|
||||
{
|
||||
@@ -39,19 +40,19 @@ class ListController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/einsaetze', name: 'app_api_assignment_list', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_API_PLACEMENTS')]
|
||||
#[Route('/api/assignments', name: 'app_api_assignment_list', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_API_ASSIGNMENTS')]
|
||||
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,
|
||||
// request without any query string, instead of the 400 naming from and to.
|
||||
AssignmentExportQueryDto $query,
|
||||
): JsonResponse {
|
||||
$rows = [];
|
||||
|
||||
$assignments = $this->assignmentRepository->findForPlacementExport(
|
||||
$query->getFrom(),
|
||||
$query->getTo(),
|
||||
$assignments = $this->assignmentRepository->findForExport(
|
||||
$query->getFromDate(),
|
||||
$query->getToDate(),
|
||||
$this->houseCodes,
|
||||
$this->jobProfileIds,
|
||||
);
|
||||
@@ -63,9 +64,9 @@ class ListController extends AbstractController
|
||||
}
|
||||
|
||||
// the range and the count, never the rows - they name people
|
||||
$this->logger->info('Placements exported', [
|
||||
'from' => $query->von,
|
||||
'to' => $query->bis,
|
||||
$this->logger->info('Assignments exported', [
|
||||
'from' => $query->from,
|
||||
'to' => $query->to,
|
||||
'assignments' => count($assignments),
|
||||
'places' => count($rows),
|
||||
]);
|
||||
@@ -94,7 +95,7 @@ class ListController extends AbstractController
|
||||
'bis' => $destination->getDateTo()->format('Y-m-d'),
|
||||
'jobprofil_id' => $jobProfile->getId(),
|
||||
'jobprofil' => $jobProfile->getName(),
|
||||
'status' => PlacementStatus::fromDisposition($disposition)->value,
|
||||
'status' => PlaceStatus::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.
|
||||
|
||||
@@ -5,9 +5,9 @@ namespace App\Enum;
|
||||
use App\Entity\Disposition;
|
||||
|
||||
/**
|
||||
* The status of a place in the n8n placements feed. The values are the feed's contract.
|
||||
* The status of a place in the n8n assignments feed. The values are the feed's contract.
|
||||
*/
|
||||
enum PlacementStatus: string
|
||||
enum PlaceStatus: string
|
||||
{
|
||||
case OPEN = 'offen';
|
||||
case REQUESTED = 'angefragt';
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* The date range of GET /api/assignments, mapped from the query string. The parameter names follow
|
||||
* the app's English URL vocabulary, like the path.
|
||||
*/
|
||||
class AssignmentExportQueryDto
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\NotBlank(message: 'from must not be empty.')]
|
||||
#[Assert\Date(message: 'from must be a date as YYYY-MM-DD.')]
|
||||
public readonly ?string $from = null,
|
||||
#[Assert\NotBlank(message: 'to must not be empty.')]
|
||||
#[Assert\Date(message: 'to must be a date as YYYY-MM-DD.')]
|
||||
#[Assert\GreaterThanOrEqual(propertyPath: 'from', message: 'to must not be before from.')]
|
||||
public readonly ?string $to = null,
|
||||
) {
|
||||
}
|
||||
|
||||
// Not getFrom()/getTo(): the property accessor prefers a getter over the public property, so
|
||||
// the GreaterThanOrEqual above would compare the string "to" with a DateTimeImmutable.
|
||||
public function getFromDate(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable($this->from);
|
||||
}
|
||||
|
||||
public function getToDate(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable($this->to);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -433,7 +433,7 @@ class AssignmentRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* The assignments the n8n placements feed reports: published, in one of the houses, for one of
|
||||
* The assignments the n8n assignments 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
|
||||
@@ -444,7 +444,7 @@ class AssignmentRepository extends ServiceEntityRepository
|
||||
*
|
||||
* @return list<Assignment>
|
||||
*/
|
||||
public function findForPlacementExport(\DateTimeImmutable $from, \DateTimeImmutable $to, array $houseCodes, array $jobProfileIds): array
|
||||
public function findForExport(\DateTimeImmutable $from, \DateTimeImmutable $to, array $houseCodes, array $jobProfileIds): array
|
||||
{
|
||||
if ([] === $houseCodes || [] === $jobProfileIds) {
|
||||
return [];
|
||||
|
||||
@@ -14,7 +14,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
* 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.
|
||||
* forms must not read assignments, and n8n reading assignments must not post forms.
|
||||
*/
|
||||
class ApiKeyTokenHandler implements AccessTokenHandlerInterface
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The n8n placements feed against real rows in db_test: which assignments it selects, and what a
|
||||
* The n8n assignments feed against real rows in db_test: which assignments it selects, and what a
|
||||
* place row says. How places are numbered is pinned in AssignmentTest.
|
||||
*
|
||||
* Every test runs inside a transaction that is rolled back, so db_test - shared between branches -
|
||||
@@ -65,24 +65,24 @@ class ListControllerTest extends WebTestCase
|
||||
|
||||
public function testAKeylessRequestIsRejected(): void
|
||||
{
|
||||
$this->client->request('GET', '/api/einsaetze?von=2026-12-01&bis=2026-12-31');
|
||||
$this->client->request('GET', '/api/assignments?from=2026-12-01&to=2026-12-31');
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAWrongKeyIsRejected(): void
|
||||
{
|
||||
$this->get('von=2026-12-01&bis=2026-12-31', 'wrong-key');
|
||||
$this->get('from=2026-12-01&to=2026-12-31', 'wrong-key');
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* The websites' key must not read placements - it names people.
|
||||
* The websites' key must not read the assignments feed - it names people.
|
||||
*/
|
||||
public function testTheApplicationFormKeyIsForbidden(): void
|
||||
{
|
||||
$this->get('von=2026-12-01&bis=2026-12-31', self::APPLICATION_FORM_KEY);
|
||||
$this->get('from=2026-12-01&to=2026-12-31', self::APPLICATION_FORM_KEY);
|
||||
|
||||
$this->assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
@@ -92,23 +92,23 @@ class ListControllerTest extends WebTestCase
|
||||
$this->get('');
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEqualsCanonicalizing(['von', 'bis'], array_column($this->decode()['errors'], 'field'));
|
||||
$this->assertEqualsCanonicalizing(['from', 'to'], array_column($this->decode()['errors'], 'field'));
|
||||
}
|
||||
|
||||
public function testAnUnusableDateIsRejected(): void
|
||||
{
|
||||
$this->get('von=01.12.2026&bis=2026-12-31');
|
||||
$this->get('from=01.12.2026&to=2026-12-31');
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertContains('von must be a date as YYYY-MM-DD.', array_column($this->decode()['errors'], 'message'));
|
||||
$this->assertContains('from must be a date as YYYY-MM-DD.', array_column($this->decode()['errors'], 'message'));
|
||||
}
|
||||
|
||||
public function testAnInvertedRangeIsRejected(): void
|
||||
{
|
||||
$this->get('von=2026-12-31&bis=2026-12-01');
|
||||
$this->get('from=2026-12-31&to=2026-12-01');
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(['bis must not be before von.'], array_column($this->decode()['errors'], 'message'));
|
||||
$this->assertSame(['to must not be before from.'], array_column($this->decode()['errors'], 'message'));
|
||||
}
|
||||
|
||||
public function testAFilledPlaceNamesThePerson(): void
|
||||
@@ -119,7 +119,7 @@ class ListControllerTest extends WebTestCase
|
||||
$this->contract($disposition, Upload::STATUS_CHECKED);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
$this->get('from=2026-12-01&to=2026-12-31');
|
||||
|
||||
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame([[
|
||||
@@ -151,7 +151,7 @@ class ListControllerTest extends WebTestCase
|
||||
$calledOff->setStatus(Disposition::STATUS_CALLED_OFF);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
$this->get('from=2026-12-01&to=2026-12-31');
|
||||
$rows = $this->decode();
|
||||
|
||||
$this->assertSame([$assignment->getId().'-1', $assignment->getId().'-2'], array_column($rows, 'platz_id'));
|
||||
@@ -170,7 +170,7 @@ class ListControllerTest extends WebTestCase
|
||||
$this->contract($disposition, Upload::STATUS_PENDING);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
$this->get('from=2026-12-01&to=2026-12-31');
|
||||
|
||||
$this->assertSame('vertrag', $this->decode()[0]['status']);
|
||||
$this->assertFalse($this->decode()[0]['vertrag_liegt_vor']);
|
||||
@@ -181,7 +181,7 @@ class ListControllerTest extends WebTestCase
|
||||
$this->dispose($this->assignment()->setSkipFormalities(true), $this->teamer());
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
$this->get('from=2026-12-01&to=2026-12-31');
|
||||
|
||||
$this->assertNull($this->decode()[0]['vertrag_liegt_vor']);
|
||||
}
|
||||
@@ -195,7 +195,7 @@ class ListControllerTest extends WebTestCase
|
||||
$this->dispose($this->assignment(), $this->teamer(dateOfBirth: '2000-12-06'));
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
$this->get('from=2026-12-01&to=2026-12-31');
|
||||
|
||||
$this->assertSame([false, true], array_column($this->decode(), 'unter_26'));
|
||||
}
|
||||
@@ -208,7 +208,7 @@ class ListControllerTest extends WebTestCase
|
||||
$this->assignment(from: '2027-01-01', to: '2027-01-08');
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
$this->get('from=2026-12-01&to=2026-12-31');
|
||||
|
||||
$this->assertSame([$startsBefore->getId(), $endsAfter->getId()], array_column($this->decode(), 'einsatz_id'));
|
||||
}
|
||||
@@ -225,7 +225,7 @@ class ListControllerTest extends WebTestCase
|
||||
$this->assignment()->setJobProfile($otherProfile);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->get('von=2026-12-01&bis=2026-12-31');
|
||||
$this->get('from=2026-12-01&to=2026-12-31');
|
||||
|
||||
$this->assertSame([$included->getId()], array_column($this->decode(), 'einsatz_id'));
|
||||
}
|
||||
@@ -299,7 +299,7 @@ class ListControllerTest extends WebTestCase
|
||||
{
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->client->request('GET', '/api/einsaetze?'.$query, server: [
|
||||
$this->client->request('GET', '/api/assignments?'.$query, server: [
|
||||
'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => $key,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user