Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,11 @@ HTTP_CLIENT_LOG_LEVEL=error

TRACK_SCREEN_INFO=false
TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300

###> NemDeling integration ###
NEMDELING_HTTP_BASIC_USER=
NEMDELING_HTTP_BASIC_PASS=
NEMDELING_TENANT_KEY=
NEMDELING_EVENT_TEMPLATE_TITLE=Event
NEMDELING_EVENT_LIST_TEMPLATE_TITLE="Event List"
###< NemDeling integration ###
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- Added NemDeling webhook integration for syncing KK events and event lists.
- Added `POST /api/v1/nemdeling/events` and `POST /api/v1/nemdeling/event-lists` endpoints.
- Ported NemDeling XML parsing, event data mapping, and playlist/slide sync from integration-service.
- Syncs slides to `event_{screen}` and `event_list_{screen}` playlists via Doctrine repositories.
- Sets slide `externalId` from NemDeling `nid` for idempotent updates.
- Added HTTP Basic authentication, sync concurrency lock (503), and `NEMDELING_*` configuration.
- Added unit tests for `NemDelingXmlParser`.

## [2.8.0] - 2026-06-23

- [#495](https://github.com/os2display/display-api-service/pull/495)
Expand Down
2 changes: 2 additions & 0 deletions config/packages/api_platform.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@ api_platform:
App\Exceptions\NotAcceptableException: 406
App\Exceptions\ConflictException: 409
App\Exceptions\TooManyRequestsException: 429
App\NemDeling\Exception\NemDelingSyncInProgressException: 503
App\NemDeling\Exception\NemDelingConfigurationException: 500
6 changes: 6 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ security:
password_path: password
success_handler: Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface
failure_handler: Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface
nemdeling:
pattern: ^/api/v1/nemdeling/
stateless: true
custom_authenticators:
- App\Security\NemDelingBasicAuthenticator
activation_code:
pattern: ^/v2/user-activation-codes/activate
stateless: true
Expand All @@ -73,6 +78,7 @@ security:
access_control:
- { path: ^/v2/authentication, roles: PUBLIC_ACCESS }
- { path: ^/v2/docs, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI
- { path: ^/api/v1/nemdeling, roles: PUBLIC_ACCESS }
- { path: ^/v2, roles: IS_AUTHENTICATED_FULLY }

role_hierarchy:
Expand Down
3 changes: 3 additions & 0 deletions config/routes/nemdeling.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
nemdeling_controllers:
resource: ../../src/NemDeling/Controller/
type: attribute
8 changes: 8 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ services:
arguments:
$cacheExpire: '%env(int:EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS)%'

App\NemDeling\NemDelingConfig:
arguments:
$basicAuthUser: '%env(default::NEMDELING_HTTP_BASIC_USER)%'
$basicAuthPass: '%env(default::NEMDELING_HTTP_BASIC_PASS)%'
$tenantKey: '%env(default::NEMDELING_TENANT_KEY)%'
$eventTemplateTitle: '%env(default::NEMDELING_EVENT_TEMPLATE_TITLE)%'
$eventListTemplateTitle: '%env(default::NEMDELING_EVENT_LIST_TEMPLATE_TITLE)%'

App\Feed\CalendarApiFeedType:
arguments:
$locationEndpoint: '%env(string:CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT)%'
Expand Down
6 changes: 6 additions & 0 deletions src/Command/Utils/ConvertEnvTo3xCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ class ConvertEnvTo3xCommand extends Command
// Screen info tracking
'TRACK_SCREEN_INFO' => 'TRACK_SCREEN_INFO',
'TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS' => 'TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS',
// NemDeling integration
'NEMDELING_HTTP_BASIC_USER' => 'NEMDELING_HTTP_BASIC_USER',
'NEMDELING_HTTP_BASIC_PASS' => 'NEMDELING_HTTP_BASIC_PASS',
'NEMDELING_TENANT_KEY' => 'NEMDELING_TENANT_KEY',
'NEMDELING_EVENT_TEMPLATE_TITLE' => 'NEMDELING_EVENT_TEMPLATE_TITLE',
'NEMDELING_EVENT_LIST_TEMPLATE_TITLE' => 'NEMDELING_EVENT_LIST_TEMPLATE_TITLE',
];

/**
Expand Down
70 changes: 70 additions & 0 deletions src/NemDeling/Controller/NemDelingEventListsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace App\NemDeling\Controller;

use App\NemDeling\Mapper\EventDataMapper;
use App\NemDeling\Model\NemDelingResult;
use App\NemDeling\NemDelingConfig;
use App\NemDeling\NemDelingService;
use App\NemDeling\NemDelingSyncLock;
use App\NemDeling\Xml\NemDelingXmlParser;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class NemDelingEventListsController
{
public function __construct(
private readonly NemDelingXmlParser $xmlParser,
private readonly EventDataMapper $eventDataMapper,
private readonly NemDelingService $nemDelingService,
private readonly NemDelingSyncLock $syncLock,
private readonly NemDelingConfig $config,
private readonly LoggerInterface $logger,
) {}

#[Route('/api/v1/nemdeling/event-lists', name: 'nemdeling_event_lists', methods: ['POST'])]
public function __invoke(Request $request): Response
{
$this->logger->debug('NemDeling event-lists webhook received.');

$this->syncLock->acquireEventLists();

try {
$body = $this->xmlParser->parse($request->getContent());
$mapped = $this->eventDataMapper->map(
$body,
$this->config->getEventListTemplateTitle(),
'eventList'
);
$results = $this->nemDelingService->syncEventLists($mapped);

return $this->createResponse($results);
} finally {
$this->syncLock->releaseEventLists();
}
}

/**
* @param NemDelingResult[] $results
*/
private function createResponse(array $results): Response
{
$payload = array_map(
static fn (NemDelingResult $result): array => [
'name' => $result->name,
'status' => $result->status,
],
$results
);

$this->logger->info('NemDeling event lists result: '.json_encode($payload, JSON_THROW_ON_ERROR));

return new Response('OK: '.json_encode($payload, JSON_THROW_ON_ERROR), Response::HTTP_CREATED);
}
}
70 changes: 70 additions & 0 deletions src/NemDeling/Controller/NemDelingEventsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace App\NemDeling\Controller;

use App\NemDeling\Mapper\EventDataMapper;
use App\NemDeling\Model\NemDelingResult;
use App\NemDeling\NemDelingConfig;
use App\NemDeling\NemDelingService;
use App\NemDeling\NemDelingSyncLock;
use App\NemDeling\Xml\NemDelingXmlParser;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class NemDelingEventsController
{
public function __construct(
private readonly NemDelingXmlParser $xmlParser,
private readonly EventDataMapper $eventDataMapper,
private readonly NemDelingService $nemDelingService,
private readonly NemDelingSyncLock $syncLock,
private readonly NemDelingConfig $config,
private readonly LoggerInterface $logger,
) {}

#[Route('/api/v1/nemdeling/events', name: 'nemdeling_events', methods: ['POST'])]
public function __invoke(Request $request): Response
{
$this->logger->debug('NemDeling events webhook received.');

$this->syncLock->acquireEvents();

try {
$body = $this->xmlParser->parse($request->getContent());
$mapped = $this->eventDataMapper->map(
$body,
$this->config->getEventTemplateTitle(),
'event'
);
$results = $this->nemDelingService->syncEvents($mapped);

return $this->createResponse($results);
} finally {
$this->syncLock->releaseEvents();
}
}

/**
* @param NemDelingResult[] $results
*/
private function createResponse(array $results): Response
{
$payload = array_map(
static fn (NemDelingResult $result): array => [
'name' => $result->name,
'status' => $result->status,
],
$results
);

$this->logger->info('NemDeling events result: '.json_encode($payload, JSON_THROW_ON_ERROR));

return new Response('OK: '.json_encode($payload, JSON_THROW_ON_ERROR), Response::HTTP_CREATED);
}
}
34 changes: 34 additions & 0 deletions src/NemDeling/EventSubscriber/NemDelingExceptionSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\NemDeling\EventSubscriber;

use App\NemDeling\Exception\NemDelingConfigurationException;
use App\NemDeling\Exception\NemDelingSyncInProgressException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;

#[AsEventListener(event: KernelEvents::EXCEPTION)]
final class NemDelingExceptionSubscriber
{
public function onKernelException(ExceptionEvent $event): void
{
if (!str_starts_with($event->getRequest()->getPathInfo(), '/api/v1/nemdeling/')) {
return;
}

$throwable = $event->getThrowable();
if ($throwable instanceof NemDelingSyncInProgressException) {
$event->setResponse(new Response($throwable->getMessage(), Response::HTTP_SERVICE_UNAVAILABLE));

return;
}

if ($throwable instanceof NemDelingConfigurationException) {
$event->setResponse(new Response($throwable->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR));
}
}
}
9 changes: 9 additions & 0 deletions src/NemDeling/Exception/NemDelingConfigurationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace App\NemDeling\Exception;

final class NemDelingConfigurationException extends \RuntimeException
{
}
13 changes: 13 additions & 0 deletions src/NemDeling/Exception/NemDelingSyncInProgressException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\NemDeling\Exception;

final class NemDelingSyncInProgressException extends \RuntimeException
{
public function __construct(string $message)
{
parent::__construct($message);
}
}
Loading
Loading