chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
+24 -3
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Entity\User;
use App\Repository\DeviceRepository;
use Doctrine\ORM\EntityManagerInterface;
@@ -42,10 +44,29 @@ class DeviceService
return $device;
}
/** Remove all image approvals and rendered assets associated with this device. */
/** Remove all image approvals and display history for this device. */
private function purgeDeviceHistory(Device $device): void
{
// Stub: will cascade-delete via ORM relationships once Image/Approval entities are added in Epic 3.
// Doctrine cascade on the Device→Approval relationship handles this automatically.
// Remove device from every image's approval list
$images = $this->em->createQueryBuilder()
->select('i')
->from(Image::class, 'i')
->join('i.approvedDevices', 'd')
->where('d = :device')
->setParameter('device', $device)
->getQuery()
->getResult();
foreach ($images as $image) {
$image->revokeForDevice($device);
}
// Purge display history (no ORM cascade fires on ownership transfer)
$this->em->createQueryBuilder()
->delete(DeviceImageHistory::class, 'h')
->where('h.device = :device')
->setParameter('device', $device)
->getQuery()
->execute();
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Enum\RenderStatus;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class RotationService
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {}
/**
* Select the next image for the device, record history, update currentImage.
* Returns null if no ready images exist in the pool.
*/
public function advance(Device $device): ?Image
{
$pool = $this->readyPool($device);
if (empty($pool)) {
$this->logger->info('rotation.no_ready_images', ['device_id' => $device->getId()]);
return null;
}
$window = min($device->getUniquenessWindow(), count($pool));
$recentIds = $this->recentImageIds($device, $window);
$candidates = array_values(array_filter(
$pool,
static fn(Image $i) => !in_array($i->getId(), $recentIds, true),
));
if (empty($candidates)) {
$candidates = $pool;
}
usort($candidates, static fn(Image $a, Image $b) => $a->getUploadedAt() <=> $b->getUploadedAt());
$image = $candidates[0];
$this->em->persist(new DeviceImageHistory($device, $image));
$device->setCurrentImage($image);
$this->em->flush();
$this->logger->info('rotation.advanced', [
'device_id' => $device->getId(),
'image_id' => $image->getId(),
'pool_size' => count($pool),
'recent_ids' => $recentIds,
]);
return $image;
}
/** @return Image[] */
private function readyPool(Device $device): array
{
return $this->em->createQueryBuilder()
->select('i')
->from(Image::class, 'i')
->join('i.approvedDevices', 'd')
->join('i.renderedAssets', 'ra')
->where('d = :device')
->andWhere('i.deletedAt IS NULL')
->andWhere('ra.deviceModel = :model')
->andWhere('ra.orientation = :orientation')
->andWhere('ra.status = :status')
->setParameter('device', $device)
->setParameter('model', $device->getModel())
->setParameter('orientation', $device->getOrientation())
->setParameter('status', RenderStatus::Ready)
->getQuery()
->getResult();
}
/** @return int[] */
private function recentImageIds(Device $device, int $limit): array
{
$rows = $this->em->createQueryBuilder()
->select('IDENTITY(h.image) AS image_id')
->from(DeviceImageHistory::class, 'h')
->where('h.device = :device')
->setParameter('device', $device)
->orderBy('h.servedAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getScalarResult();
return array_column($rows, 'image_id');
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Image;
use App\Entity\SharedImage;
use App\Entity\User;
use App\Enum\TokenType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\MailerInterface;
class ShareService
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TokenService $tokenService,
private readonly MailerInterface $mailer,
#[Autowire('%env(int:SHARE_TOKEN_TTL_DAYS)%')]
private readonly int $tokenTtlDays,
#[Autowire('%env(MAILER_SENDER)%')]
private readonly string $mailerSender,
) {}
public function share(Image $image, User $sharer, string $recipientEmail): SharedImage
{
$recipient = $this->em->getRepository(User::class)->findOneBy(['email' => $recipientEmail]);
if ($recipient === null) {
throw new \InvalidArgumentException('Recipient not found. They must have an account to receive shared photos.');
}
if ($recipient->getId() === $sharer->getId()) {
throw new \InvalidArgumentException('You cannot share a photo with yourself.');
}
// Idempotent: return existing pending share if one already exists
$existing = $this->em->getRepository(SharedImage::class)->findOneBy([
'sourceImage' => $image,
'recipientUser' => $recipient,
]);
if ($existing) {
return $existing;
}
$shared = new SharedImage($image, $recipient, $sharer);
$this->em->persist($shared);
$approveToken = $this->tokenService->issue(TokenType::ShareApprove, $image, $recipient, $recipientEmail, $this->tokenTtlDays);
$declineToken = $this->tokenService->issue(TokenType::ShareDecline, $image, $recipient, $recipientEmail, $this->tokenTtlDays);
$this->em->flush();
$email = (new TemplatedEmail())
->from($this->mailerSender)
->to($recipientEmail)
->subject($sharer->getEmail() . ' shared a photo with you')
->htmlTemplate('emails/share_notification.html.twig')
->textTemplate('emails/share_notification.txt.twig')
->context([
'sharer' => $sharer,
'image' => $image,
'approveToken' => $approveToken,
'declineToken' => $declineToken,
]);
$this->mailer->send($email);
return $shared;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Image;
use App\Entity\Token;
use App\Entity\User;
use App\Enum\TokenType;
use App\Repository\TokenRepository;
use Doctrine\ORM\EntityManagerInterface;
class TokenService
{
public function __construct(
private readonly TokenRepository $tokenRepository,
private readonly EntityManagerInterface $em,
) {}
public function issue(
TokenType $type,
Image $image,
?User $recipient,
?string $recipientEmail,
int $ttlDays,
): Token {
$token = new Token($type, $image, $recipient, $recipientEmail, $ttlDays);
$this->em->persist($token);
return $token;
}
/** @throws \RuntimeException if token is not found, already used, or expired */
public function consume(string $uuid, TokenType $type): Token
{
$token = $this->tokenRepository->findValidToken($uuid, $type);
if (!$token) {
throw new \RuntimeException('Token not found, already used, or expired.');
}
$token->consume();
$this->em->flush();
return $token;
}
/** Returns a valid token without consuming it, or null. */
public function findValid(string $uuid, TokenType $type): ?Token
{
return $this->tokenRepository->findValidToken($uuid, $type);
}
}