<?php
|
declare(strict_types=1);
|
|
namespace App\Controller;
|
|
use App\Repository\BookRepository;
|
use Doctrine\ORM\EntityManagerInterface;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\Routing\Attribute\Route;
|
|
class InventoryController extends AbstractController {
|
public const int MAX_SCANS_PER_REQUEST = 500;
|
|
public const string SAVED = 'saved';
|
public const string RETURNED = 'returned';
|
public const string UNKNOWN_BOOK = 'unknown_book';
|
public const string INVALID = 'invalid';
|
|
#[Route('/inventory/', name: 'inventory', methods: ['GET'])]
|
public function index(): Response {
|
return $this->render('inventory/index.html.twig');
|
}
|
|
/**
|
* The service worker that caches the page. It is served by the application rather than from
|
* public/ so that its scope covers /inventory/ and so that it knows the URLs to cache.
|
*/
|
#[Route('/inventory/sw.js', name: 'inventory_service_worker', methods: ['GET'])]
|
public function serviceWorker(): Response {
|
$response = $this->render('inventory/sw.js.twig');
|
$response->headers->set('Content-Type', 'text/javascript; charset=UTF-8');
|
$response->headers->set('Cache-Control', 'no-cache');
|
|
return $response;
|
}
|
|
#[Route('/inventory/books', name: 'inventory_books', methods: ['GET'])]
|
public function books(BookRepository $books): JsonResponse {
|
$response = $this->json(['books' => $books->findInventoryData()]);
|
$response->headers->set('Cache-Control', 'no-store');
|
|
return $response;
|
}
|
|
/**
|
* Accepts {"<book ID>": "<location>", ...} and answers {"results": {"<book ID>": "<status>"}}.
|
* Every listed book is marked as seen now.
|
* A book found on the shelf while on loan is marked as returned.
|
*/
|
#[Route('/inventory/sync', name: 'inventory_sync', methods: ['POST'])]
|
public function sync(Request $request, BookRepository $books, EntityManagerInterface $entityManager): JsonResponse {
|
if ($request->getContentTypeFormat() !== 'json') {
|
return $this->json(['error' => 'Expected a JSON body.'], Response::HTTP_UNSUPPORTED_MEDIA_TYPE);
|
}
|
|
// Decoded as an object: as an array, {"0": "..."} would be indistinguishable from a list.
|
try {
|
$payload = json_decode($request->getContent(), false, 2, \JSON_THROW_ON_ERROR);
|
} catch (\JsonException) {
|
$payload = null;
|
}
|
|
$scans = $payload instanceof \stdClass ? get_object_vars($payload) : null;
|
if ($scans === null || \count($scans) > self::MAX_SCANS_PER_REQUEST) {
|
return $this->json(['error' => 'Expected an object mapping at most ' . self::MAX_SCANS_PER_REQUEST . ' book IDs to locations.'], Response::HTTP_BAD_REQUEST);
|
}
|
|
$valid = [];
|
$results = [];
|
foreach ($scans as $id => $location) {
|
$location = \is_string($location) ? trim($location) : '';
|
|
if (!\is_int($id) || $id < 0 || $location === '' || mb_strlen($location) > 255) {
|
$results[$id] = self::INVALID;
|
} else {
|
$valid[$id] = $location;
|
$results[$id] = self::UNKNOWN_BOOK;
|
}
|
}
|
|
$now = new \DateTimeImmutable();
|
foreach ($valid === [] ? [] : $books->findForInventorySync(array_keys($valid)) as $book) {
|
$results[$book->getId()] = self::SAVED;
|
|
if ($book->isBorrowed()) {
|
$book->giveBack($now);
|
$results[$book->getId()] = self::RETURNED;
|
}
|
|
$book->seenAt($valid[$book->getId()], $now);
|
}
|
|
$entityManager->flush();
|
|
return $this->json(['results' => (object)$results]);
|
}
|
}
|