Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/codex-api/src/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,8 @@ The following paths are exempt from rate limiting:
v1::handlers::record_reading_sessions,
v1::handlers::get_reading_stats,
v1::handlers::get_reading_coverage,
v1::handlers::get_orphaned_reading_history,
v1::handlers::purge_orphaned_reading_history,

// Reading progress endpoints
v1::handlers::update_reading_progress,
Expand Down Expand Up @@ -1046,6 +1048,8 @@ The following paths are exempt from rate limiting:
v1::dto::ReadingStatsGranularity,
v1::dto::ReadingStatsSort,
v1::dto::ReadingCoverageDto,
v1::dto::OrphanedHistoryDto,
v1::dto::PurgedOrphanedHistoryDto,

// Reading session DTOs
v1::dto::RecordReadingSessionsRequest,
Expand Down
67 changes: 62 additions & 5 deletions crates/codex-api/src/routes/v1/dto/reading_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

use chrono::{DateTime, Utc};
use codex_db::repositories::{
DurationBreakdown, ReadingByDevice, ReadingByFormat, ReadingBySeries, ReadingCoverage,
ReadingPeriod, ReadingSummary, StatsGranularity, StatsSort,
DurationBreakdown, OrphanedHistory, PurgedOrphanedHistory, ReadingByDevice, ReadingByFormat,
ReadingBySeries, ReadingCoverage, ReadingPeriod, ReadingSummary, StatsGranularity, StatsSort,
};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
Expand Down Expand Up @@ -219,9 +219,17 @@ impl From<ReadingByDevice> for ReadingByDeviceDto {
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReadingBySeriesDto {
pub series_id: Uuid,
/// Null on the removed-from-library row.
pub series_id: Option<Uuid>,
/// Null on the removed-from-library row.
#[schema(example = "Berserk")]
pub series_name: String,
pub series_name: Option<String>,
/// True on the single row that gathers reading whose book has since been
/// deleted from the server. That time still counts towards every total,
/// but which series it belonged to is no longer known. Its `books` and
/// `booksFinished` are always 0: both count distinct books, and deleted
/// books cannot be told apart.
pub removed_from_library: bool,
pub duration: DurationBreakdownDto,
pub pages_read: i64,
pub sessions: i64,
Expand All @@ -235,6 +243,7 @@ pub struct ReadingBySeriesDto {
impl From<ReadingBySeries> for ReadingBySeriesDto {
fn from(value: ReadingBySeries) -> Self {
Self {
removed_from_library: value.series_id.is_none(),
series_id: value.series_id,
series_name: value.series_name,
duration: value.duration.into(),
Expand All @@ -250,8 +259,12 @@ impl From<ReadingBySeries> for ReadingBySeriesDto {
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReadingByFormatDto {
/// Null on the removed-from-library row.
#[schema(example = "cbz")]
pub format: String,
pub format: Option<String>,
/// True on the single row that gathers reading whose book has since been
/// deleted from the server, and whose format is therefore unknown.
pub removed_from_library: bool,
pub duration: DurationBreakdownDto,
pub pages_read: i64,
pub sessions: i64,
Expand All @@ -261,6 +274,7 @@ pub struct ReadingByFormatDto {
impl From<ReadingByFormat> for ReadingByFormatDto {
fn from(value: ReadingByFormat) -> Self {
Self {
removed_from_library: value.format.is_none(),
format: value.format,
duration: value.duration.into(),
pages_read: value.pages_read,
Expand Down Expand Up @@ -308,3 +322,46 @@ pub struct ReadingStatsResponse {
pub series: Vec<ReadingBySeriesDto>,
pub formats: Vec<ReadingByFormatDto>,
}

/// What purging the removed-from-library history deleted.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PurgedOrphanedHistoryDto {
/// Reading sessions deleted. Their time no longer counts anywhere.
pub sessions_removed: u64,
/// Finished read-throughs deleted.
pub completions_removed: u64,
}

impl From<PurgedOrphanedHistory> for PurgedOrphanedHistoryDto {
fn from(value: PurgedOrphanedHistory) -> Self {
Self {
sessions_removed: value.sessions,
completions_removed: value.completions,
}
}
}

/// The caller's reading history whose book has since been deleted, across
/// every date. What a purge would remove.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct OrphanedHistoryDto {
pub duration: DurationBreakdownDto,
pub pages_read: i64,
/// Sittings, counted as the dashboard counts them.
pub sessions: i64,
/// Finished read-throughs.
pub completions: u64,
}

impl From<OrphanedHistory> for OrphanedHistoryDto {
fn from(value: OrphanedHistory) -> Self {
Self {
duration: value.duration.into(),
pages_read: value.pages_read,
sessions: value.sessions,
completions: value.completions,
}
}
}
82 changes: 78 additions & 4 deletions crates/codex-api/src/routes/v1/handlers/reading_stats.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
//! Reading statistics, aggregated from the session log.

use super::super::dto::{
DurationBreakdownDto, ReadingByDeviceDto, ReadingByFormatDto, ReadingBySeriesDto,
ReadingCoverageDto, ReadingPeriodDto, ReadingStatsGranularity, ReadingStatsQuery,
ReadingStatsResponse, ReadingStatsSort, ReadingSummaryDto,
DurationBreakdownDto, OrphanedHistoryDto, PurgedOrphanedHistoryDto, ReadingByDeviceDto,
ReadingByFormatDto, ReadingBySeriesDto, ReadingCoverageDto, ReadingPeriodDto,
ReadingStatsGranularity, ReadingStatsQuery, ReadingStatsResponse, ReadingStatsSort,
ReadingSummaryDto,
};
use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission};
use axum::{
Expand Down Expand Up @@ -34,7 +35,11 @@ const MAX_TZ_OFFSET_MINUTES: i32 = 14 * 60;

#[derive(OpenApi)]
#[openapi(
paths(get_reading_stats),
paths(
get_reading_stats,
get_orphaned_reading_history,
purge_orphaned_reading_history
),
components(schemas(
ReadingStatsResponse,
ReadingSummaryDto,
Expand All @@ -44,6 +49,8 @@ const MAX_TZ_OFFSET_MINUTES: i32 = 14 * 60;
ReadingByFormatDto,
DurationBreakdownDto,
ReadingStatsGranularity,
OrphanedHistoryDto,
PurgedOrphanedHistoryDto,
)),
tags(
(name = "Reading Statistics", description = "Aggregated reading time and pages")
Expand Down Expand Up @@ -186,3 +193,70 @@ pub async fn get_reading_coverage(

Ok(Json(coverage.into()))
}

/// The caller's reading history for books that no longer exist, in total
///
/// Unwindowed: exactly what `DELETE /api/v1/reading-stats/orphaned` would
/// remove, so a client can say so before asking the reader to confirm.
#[utoipa::path(
get,
path = "/api/v1/reading-stats/orphaned",
responses(
(status = 200, description = "Totals of the detached history", body = OrphanedHistoryDto),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(
("jwt_bearer" = []),
("api_key" = [])
),
tag = "Reading Statistics"
)]
pub async fn get_orphaned_reading_history(
State(state): State<Arc<AppState>>,
auth: AuthContext,
) -> Result<Json<OrphanedHistoryDto>, ApiError> {
auth.require_permission(&Permission::ProgressRead)?;

let totals = ReadingStatsRepository::orphaned_totals(&state.db, auth.user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to total orphaned history: {}", e)))?;

Ok(Json(totals.into()))
}

/// Delete the caller's reading history for books that no longer exist
///
/// When a book is deleted from the server its reading sessions and finished
/// read-throughs are kept, so the time still counts towards every statistic;
/// the series and format breakdowns show it as one "removed from library" row.
/// This discards those rows for the caller, and only for the caller.
///
/// Irreversible. Only history already detached from any book is touched;
/// attributed reading is never affected.
#[utoipa::path(
delete,
path = "/api/v1/reading-stats/orphaned",
responses(
(status = 200, description = "What was deleted", body = PurgedOrphanedHistoryDto),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(
("jwt_bearer" = []),
("api_key" = [])
),
tag = "Reading Statistics"
)]
pub async fn purge_orphaned_reading_history(
State(state): State<Arc<AppState>>,
auth: AuthContext,
) -> Result<Json<PurgedOrphanedHistoryDto>, ApiError> {
auth.require_permission(&Permission::ProgressWrite)?;

let purged = ReadingStatsRepository::purge_orphaned(&state.db, auth.user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to purge orphaned history: {}", e)))?;

Ok(Json(purged.into()))
}
7 changes: 7 additions & 0 deletions crates/codex-api/src/routes/v1/routes/books.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ pub fn routes(_state: Arc<AppState>) -> Router<Arc<AppState>> {
"/reading-stats/coverage",
get(handlers::get_reading_coverage),
)
// Reading whose book has been deleted is kept until the reader says
// otherwise.
.route(
"/reading-stats/orphaned",
get(handlers::get_orphaned_reading_history)
.delete(handlers::purge_orphaned_reading_history),
)
// Mark as read/unread routes
.route("/books/{book_id}/read", post(handlers::mark_book_as_read))
.route(
Expand Down
13 changes: 9 additions & 4 deletions crates/codex-db/src/entities/read_completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
//!
//! An append-only log of finished read-throughs: one row per completed pass of
//! one book by one user. Rows are inserted when a book is completed and only
//! ever removed by an explicit history reset (or by a cascade when the user or
//! book is deleted). Nothing updates them.
//! ever removed by an explicit history reset or by a cascade when the user is
//! deleted. Deleting the book keeps the row and clears `book_id`. Nothing else
//! updates them.
//!
//! This is deliberately separate from `read_progress`, which tracks the
//! *current* pass and is deleted when a book is marked unread. Keeping the
Expand All @@ -21,7 +22,11 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub user_id: Uuid,
pub book_id: Uuid,
/// The finished book. `None` once that book has been hard-deleted: the fact
/// that it was finished survives the file, only the attribution is lost.
/// Every write path supplies a book; `None` is only ever reached by the
/// foreign key's `ON DELETE SET NULL`.
pub book_id: Option<Uuid>,
/// When this pass started, copied from the `read_progress` row that was
/// current when the book completed.
pub started_at: DateTime<Utc>,
Expand All @@ -36,7 +41,7 @@ pub enum Relation {
from = "Column::BookId",
to = "super::books::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
on_delete = "SetNull"
)]
Books,
#[sea_orm(
Expand Down
9 changes: 7 additions & 2 deletions crates/codex-db/src/entities/reading_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub user_id: Uuid,
pub book_id: Uuid,
/// The book this reading happened in. `None` once that book has been
/// hard-deleted: the session outlives the file so the time still counts
/// towards the reader's statistics, but nothing can say which book it was.
/// Every write path supplies a book; `None` is only ever reached by the
/// foreign key's `ON DELETE SET NULL`.
pub book_id: Option<Uuid>,
/// Stable per install. Producers with no device concept of their own
/// (Komga, OPDS) derive one from the API key or user agent.
pub device_id: String,
Expand Down Expand Up @@ -146,7 +151,7 @@ pub enum Relation {
from = "Column::BookId",
to = "super::books::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
on_delete = "SetNull"
)]
Books,
#[sea_orm(
Expand Down
6 changes: 3 additions & 3 deletions crates/codex-db/src/repositories/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ pub use read_progress::ReadProgressRepository;
pub use reading_sessions::{AppendOutcome, DeviceContext, NewSession, ReadingSessionRepository};
#[allow(unused_imports)]
pub use reading_stats::{
DurationBreakdown, ReadingByDevice, ReadingByFormat, ReadingBySeries, ReadingCoverage,
ReadingPeriod, ReadingStatsRepository, ReadingSummary, StatsGranularity, StatsSort,
StatsWindow,
DurationBreakdown, OrphanedHistory, PurgedOrphanedHistory, ReadingByDevice, ReadingByFormat,
ReadingBySeries, ReadingCoverage, ReadingPeriod, ReadingStatsRepository, ReadingSummary,
StatsGranularity, StatsSort, StatsWindow,
};
#[allow(unused_imports)]
pub use refresh_token::{NewRefreshToken, RefreshTokenRepository};
Expand Down
11 changes: 7 additions & 4 deletions crates/codex-db/src/repositories/read_completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ impl ReadCompletionRepository {
let row = read_completions::ActiveModel {
id: Set(Uuid::new_v4()),
user_id: Set(user_id),
book_id: Set(book_id),
book_id: Set(Some(book_id)),
started_at: Set(started_at),
completed_at: Set(completed_at),
};
Expand Down Expand Up @@ -240,8 +240,11 @@ impl ReadCompletionRepository {
.all(db)
.await?;
for row in rows {
let Some(book_id) = row.book_id else {
continue;
};
by_book
.entry(row.book_id)
.entry(book_id)
.or_default()
.push((row.started_at, row.completed_at));
}
Expand Down Expand Up @@ -611,8 +614,8 @@ mod tests {
.unwrap();
assert_eq!(entries.len(), 2, "the unrelated series must not leak in");
// Newest first.
assert_eq!(entries[0].book_id, second.id);
assert_eq!(entries[1].book_id, first.id);
assert_eq!(entries[0].book_id, Some(second.id));
assert_eq!(entries[1].book_id, Some(first.id));
}

#[tokio::test]
Expand Down
2 changes: 1 addition & 1 deletion crates/codex-db/src/repositories/read_progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1771,7 +1771,7 @@ mod tests {
rows.push(reading_sessions::ActiveModel {
id: Set(Uuid::new_v4()),
user_id: Set(user.id),
book_id: Set(book.id),
book_id: Set(Some(book.id)),
device_id: Set(format!("bench-device-{}", i % 3)),
device_name: Set(None),
pass: Set(pass),
Expand Down
2 changes: 1 addition & 1 deletion crates/codex-db/src/repositories/reading_sessions/fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ mod tests {
Session {
id: Uuid::new_v4(),
user_id: Uuid::nil(),
book_id: Uuid::nil(),
book_id: Some(Uuid::nil()),
device_id: self.device.to_string(),
device_name: None,
pass: self.pass,
Expand Down
2 changes: 1 addition & 1 deletion crates/codex-db/src/repositories/reading_sessions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ impl ReadingSessionRepository {
let model = reading_sessions::ActiveModel {
id: Set(session.id),
user_id: Set(session.user_id),
book_id: Set(session.book_id),
book_id: Set(Some(session.book_id)),
device_id: Set(session.device_id),
device_name: Set(session.device_name),
pass: Set(pass),
Expand Down
Loading
Loading