Files
pictureFrame-webApp/frontend/src/stores/devices.ts
T
football2801 920de623a0
CI / test (push) Has been cancelled
feat(devices): owner can mark a frame as sold and unlink it pre-emptively
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>
2026-05-08 14:53:51 -04:00

78 lines
3.0 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Device } from '@/types'
export const useDevicesStore = defineStore('devices', () => {
const devices = ref<Device[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
/**
* Fetch the device list. Pass `silent: true` from background refreshes
* (pull-to-refresh, visibility-change polling) so the loading spinner
* doesn't blink and replace the existing cards mid-fetch.
*/
async function fetchDevices(opts: { silent?: boolean } = {}) {
if (!opts.silent) loading.value = true
error.value = null
try {
const res = await fetch('/api/devices')
if (!res.ok) throw new Error('Failed to load devices')
devices.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalMinutes' | 'wakeTimes' | 'timezone' | 'uniquenessWindow' | 'rotationMode' | 'prioritizeNeverShown'>>) {
const res = await fetch(`/api/devices/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
if (!res.ok) throw new Error('Failed to update device')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === id)
if (idx !== -1) devices.value[idx] = updated
return updated
}
async function lockImage(deviceId: number, imageId: number): Promise<Device> {
const res = await fetch(`/api/devices/${deviceId}/lock`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageId }),
})
if (!res.ok) throw new Error('Failed to lock image')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === deviceId)
if (idx !== -1) devices.value[idx] = updated
return updated
}
/**
* Owner-initiated remove. Hits the API DELETE; on success splices the
* device out of the local list so the UI updates immediately. The
* Mercure subscription will also receive a `{deleted: true}` push for
* any other tabs the user has open.
*/
async function removeDevice(id: number): Promise<void> {
const res = await fetch(`/api/devices/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to remove device')
devices.value = devices.value.filter(d => d.id !== id)
}
async function unlockImage(deviceId: number): Promise<Device> {
const res = await fetch(`/api/devices/${deviceId}/lock`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to unlock')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === deviceId)
if (idx !== -1) devices.value[idx] = updated
return updated
}
return { devices, loading, error, fetchDevices, updateDevice, removeDevice, lockImage, unlockImage }
})