feat(stats): per-device reading breakdown + device attribution on progress syncs
- Attribution: registered clients may now send their device token in
X-Device-Token on PUT /api/media-items/:id/progress (the user JWT
stays primary). The token must map to a device OWNED BY the caller —
foreign/invalid/absent tokens soft-fail to plain web attribution so a
stats decoration can never break a sync. Attributed pushes capture
device_id (FK-safe), device_metadata.source = the registered device
name ('Pixel 9', 'Kindle Paperwhite'), and last_sync surfaces show
that device. Side effect worth having: app-vs-web pushes used to be
LWW-invisible (both sources read 'web'); they now surface as
legitimate cross-source progress conflicts when they diverge.
- /stats gains a By Device panel alongside By Library (grid 3-wide):
reading minutes + sessions + books per registered device, plus one
Web / unsynced bucket for device-less pushes so the panel always
reconciles with the headline totals. Range-aware, family-lens
scoped; renderBars shared between panels in stats.ts.
- The device identity IS the registered platform: devices rows already
carry the name/type the client registered (app sends Build.MODEL +
'mobile' at /api/devices/register, KOReader/Kobo likewise) — no new
identity plumbing on the server.
Tests: capture attributed to the device (row device_id + source
metadata + last_sync_source), foreign/nonexistent tokens soft-fail,
device buckets reconcile (80 attributed + 75 unsynced = 155 total).
Full suite PASS.
This commit is contained in:
1 parent
be10adc24b
commit
7a604c2fec
8 files changed
+399
-20
No files matched your search
@@ -1108,6 +1108,31 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
// Reading-stats attribution: registered clients may send their device
|
||||
// token in X-Device-Token (the user JWT stays the primary auth). A
|
||||
// token that maps to a device owned by THIS user attaches the push —
|
||||
// and everything the reading history records about it — to that
|
||||
// device's registered name/type ("Pixel 9" · mobile, "Kindle… " ·
|
||||
// koreader). Absent/invalid/foreign tokens soft-fail to the plain
|
||||
// web attribution; a stats decoration must never fail a sync.
|
||||
deviceID := pgtype.UUID{}
|
||||
deviceSource := "web"
|
||||
deviceLabel := ""
|
||||
deviceType := ""
|
||||
if token := c.Request().Header.Get("X-Device-Token"); token != "" {
|
||||
device, devErr := mh.db.GetDeviceByAuthToken(c.Request().Context(), token)
|
||||
if devErr == nil && device.UserID.Valid && uuid.UUID(device.UserID.Bytes) == userUUID {
|
||||
deviceID = device.ID
|
||||
deviceLabel = device.DeviceName
|
||||
deviceType = device.DeviceType
|
||||
deviceSource = device.DeviceName
|
||||
if strings.TrimSpace(deviceSource) == "" {
|
||||
deviceSource = device.DeviceType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var req struct {
|
||||
CurrentPage *int32 `json:"current_page"`
|
||||
TotalPages *int32 `json:"total_pages"`
|
||||
@@ -1130,8 +1155,10 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
saveReq := wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Source: "web",
|
||||
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Source: deviceSource,
|
||||
DeviceID: deviceID,
|
||||
DeviceType: deviceType,
|
||||
DeviceName: deviceLabel,
|
||||
Percentage: req.Percentage,
|
||||
Epubcfi: req.Epubcfi,
|
||||
ContextText: req.ContextText,
|
||||
@@ -1144,8 +1171,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
ScrollX: req.ScrollX,
|
||||
ScrollY: req.ScrollY,
|
||||
ReadingMode: req.ReadingMode,
|
||||
DeviceType: "web",
|
||||
DeviceName: "Web",
|
||||
Broadcast: true,
|
||||
}
|
||||
if req.CurrentPage != nil {
|
||||
|
||||
Reference in new issue
Block a user