feat(devices): owner can mark a frame as sold and unlink it pre-emptively
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Pairs with the new claim-on-takeover checkbox: now the seller can purge
their data BEFORE handing the device over, so even if they forget to
hold the BOOT button to wipe NVS, the next owner can't accidentally pull
their photos.
Backend:
- DELETE /api/devices/{id}: owner-only (404 for cross-tenant). Revokes
image-device approvals, drops history rows, removes the Device row
entirely so the MAC is unclaimed. The next poll from that physical
frame returns 404 → setup QR for the next owner.
- DeviceService::deleteDeviceForOwner extracts the cleanup so the
controller stays thin.
- Mercure publish on delete sends {id, deleted: true} so any other
open PWA tabs splice the row out instantly.
Frontend:
- Settings sheet (BaseBottomSheet): "Remove this frame" link below
Save, in danger red with an explanatory hint about when to use it.
- Native window.confirm gate — destructive + irreversible, the
weight of native-confirm is honest. (A bespoke modal would be
polish.)
- useDeviceMercure: handles the {id, deleted: true} sentinel — splices
the device out + closes its own EventSource for that topic.
- useDevicesStore.removeDevice: DELETE + local store filter.
Tests added:
- DeviceApiControllerTest: 4 cases — happy-path delete purges
everything, 404 cross-tenant, anon redirects to login, and
post-delete the device-poll endpoint 404s (fresh-MAC guarantee).
- HomeView.test.ts: confirm-yes calls store + closes sheet,
confirm-cancel does NOT call removeDevice.
- useDeviceMercure.test.ts: deletion sentinel splices the device
out and closes the EventSource.
Coverage: 99.71% lines / 98.21% methods backend, 98.31% lines frontend.
558 tests total via ddev tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ use App\Enum\Orientation;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Enum\RotationMode;
|
||||
use App\Service\DeviceSerializer;
|
||||
use App\Service\DeviceService;
|
||||
use App\Service\MercurePublisher;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -45,6 +46,7 @@ class DeviceApiController extends AbstractController
|
||||
private readonly string $projectDir,
|
||||
private readonly DeviceSerializer $serializer,
|
||||
private readonly MercurePublisher $mercure,
|
||||
private readonly DeviceService $deviceService,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'api_devices_list', methods: ['GET'])]
|
||||
@@ -193,6 +195,39 @@ class DeviceApiController extends AbstractController
|
||||
return $this->json($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-initiated removal — "I sold this frame / I'm not using it
|
||||
* anymore." Revokes image approvals, drops display history, and removes
|
||||
* the Device row entirely so the MAC is unclaimed. The next time that
|
||||
* physical device polls, the server returns 404 and the device shows
|
||||
* its setup QR for whoever provisions it next.
|
||||
*
|
||||
* Pre-emptive purge: if the user runs this BEFORE handing the device
|
||||
* away, the new owner can't accidentally pull their photos even if the
|
||||
* seller forgets to hold the BOOT button to wipe NVS first.
|
||||
*/
|
||||
#[Route('/{id}', name: 'api_device_delete', methods: ['DELETE'])]
|
||||
public function delete(int $id, EntityManagerInterface $em): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);
|
||||
|
||||
if (!$device) {
|
||||
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$deviceId = (int) $device->getId();
|
||||
$this->deviceService->deleteDeviceForOwner($device);
|
||||
|
||||
// Tell any subscribed PWA tabs the device is gone so multi-tab
|
||||
// sessions and Mom's-phone-still-watching stay consistent. The
|
||||
// payload is a deletion sentinel; the SPA listener splices the row
|
||||
// out of its store on receipt.
|
||||
$this->mercure->publishDevice($deviceId, ['id' => $deviceId, 'deleted' => true]);
|
||||
|
||||
return new Response(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a PNG preview of the image **currently shown on the frame**,
|
||||
|
||||
@@ -77,6 +77,23 @@ class DeviceService
|
||||
return $device;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-initiated delete. Revokes image-device approvals, drops history,
|
||||
* and removes the Device row entirely so the MAC is fully unclaimed —
|
||||
* the next provisioning poll from this device will return 404 and the
|
||||
* frame will display the setup QR for whoever provisions it next.
|
||||
*
|
||||
* The user-facing pitch is "I'm selling/giving this away" — by purging
|
||||
* before handing the device over, the new owner can't accidentally pull
|
||||
* the seller's photos even if the seller forgets to wipe NVS first.
|
||||
*/
|
||||
public function deleteDeviceForOwner(Device $device): void
|
||||
{
|
||||
$this->purgeDeviceHistory($device);
|
||||
$this->em->remove($device);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/** Remove all image approvals and display history for this device. */
|
||||
private function purgeDeviceHistory(Device $device): void
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user