feat(database): add analytics and book matching queries
Add analytics queries: - GetUserReadingHistory: detailed reading history with device info - GetUserDeviceUsage: device usage statistics (sync count, time spent) - GetPopularBooks: most read books with completion rates Add book matching queries: - GetUnlinkedBookByID: fetch single unlinked book - DeleteUnlinkedBook: remove resolved unlinked book - ListUnresolvedUnlinkedBooks: paginated list of unresolved books
This commit is contained in:
@@ -1407,6 +1407,16 @@ func (q *Queries) DeleteSystemConfig(ctx context.Context, key string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteUnlinkedBook = `-- name: DeleteUnlinkedBook :exec
|
||||
DELETE FROM unlinked_books WHERE id = $1
|
||||
`
|
||||
|
||||
// Delete unlinked book
|
||||
func (q *Queries) DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, DeleteUnlinkedBook, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteUser = `-- name: DeleteUser :exec
|
||||
DELETE FROM users WHERE id = $1
|
||||
`
|
||||
@@ -3482,6 +3492,64 @@ func (q *Queries) GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUI
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetPopularBooks = `-- name: GetPopularBooks :many
|
||||
SELECT
|
||||
mi.id,
|
||||
mi.title,
|
||||
mi.author,
|
||||
COUNT(*) as read_count,
|
||||
AVG(rh.progress_percentage) as avg_completion,
|
||||
MAX(rh.created_at) as last_read
|
||||
FROM media_items mi
|
||||
JOIN reading_history rh ON rh.media_item_id = mi.id
|
||||
WHERE rh.user_id = $1
|
||||
GROUP BY mi.id, mi.title, mi.author
|
||||
ORDER BY read_count DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type GetPopularBooksParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
Limit int32 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type GetPopularBooksRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
ReadCount int64 `db:"read_count" json:"read_count"`
|
||||
AvgCompletion float64 `db:"avg_completion" json:"avg_completion"`
|
||||
LastRead interface{} `db:"last_read" json:"last_read"`
|
||||
}
|
||||
|
||||
// Get most popular books for a user
|
||||
func (q *Queries) GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetPopularBooks, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetPopularBooksRow{}
|
||||
for rows.Next() {
|
||||
var i GetPopularBooksRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.ReadCount,
|
||||
&i.AvgCompletion,
|
||||
&i.LastRead,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetReadingHistory = `-- name: GetReadingHistory :many
|
||||
SELECT id, user_id, media_item_id, device_id, progress_percentage, reading_session_start, reading_session_end, pages_read, time_spent_seconds, device_metadata, created_at
|
||||
FROM reading_history
|
||||
@@ -3899,6 +3967,32 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one
|
||||
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE id = $1
|
||||
`
|
||||
|
||||
// Get unlinked book by ID
|
||||
func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error) {
|
||||
row := q.db.QueryRow(ctx, GetUnlinkedBookByID, id)
|
||||
var i UnlinkedBooks
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.DeviceID,
|
||||
&i.ContentID,
|
||||
&i.FilePath,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.ConfidenceScore,
|
||||
&i.Resolved,
|
||||
&i.MediaItemID,
|
||||
&i.ResolvedAt,
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
@@ -4094,6 +4188,58 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserDeviceUsage = `-- name: GetUserDeviceUsage :many
|
||||
SELECT
|
||||
d.id,
|
||||
d.device_name,
|
||||
d.device_type,
|
||||
COUNT(*) as sync_count,
|
||||
MAX(rh.created_at) as last_sync,
|
||||
SUM(rh.time_spent_seconds) as total_time_seconds
|
||||
FROM devices d
|
||||
JOIN reading_history rh ON rh.device_id = d.id
|
||||
WHERE d.user_id = $1
|
||||
GROUP BY d.id, d.device_name, d.device_type
|
||||
ORDER BY sync_count DESC
|
||||
`
|
||||
|
||||
type GetUserDeviceUsageRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
SyncCount int64 `db:"sync_count" json:"sync_count"`
|
||||
LastSync interface{} `db:"last_sync" json:"last_sync"`
|
||||
TotalTimeSeconds int64 `db:"total_time_seconds" json:"total_time_seconds"`
|
||||
}
|
||||
|
||||
// Get user device usage statistics
|
||||
func (q *Queries) GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetUserDeviceUsage, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetUserDeviceUsageRow{}
|
||||
for rows.Next() {
|
||||
var i GetUserDeviceUsageRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
&i.SyncCount,
|
||||
&i.LastSync,
|
||||
&i.TotalTimeSeconds,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetUserForLogin = `-- name: GetUserForLogin :one
|
||||
SELECT id, email, username, password_hash, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1 OR username = $1
|
||||
`
|
||||
@@ -4296,6 +4442,95 @@ func (q *Queries) GetUserProgressForBooks(ctx context.Context, arg GetUserProgre
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetUserReadingHistory = `-- name: GetUserReadingHistory :many
|
||||
|
||||
SELECT
|
||||
rh.id,
|
||||
rh.user_id,
|
||||
rh.media_item_id,
|
||||
rh.device_id,
|
||||
rh.progress_percentage,
|
||||
rh.reading_session_start,
|
||||
rh.reading_session_end,
|
||||
rh.pages_read,
|
||||
rh.time_spent_seconds,
|
||||
rh.device_metadata,
|
||||
rh.created_at,
|
||||
mi.title,
|
||||
mi.author,
|
||||
d.device_name,
|
||||
d.device_type
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON rh.media_item_id = mi.id
|
||||
LEFT JOIN devices d ON rh.device_id = d.id
|
||||
WHERE rh.user_id = $1
|
||||
AND rh.created_at >= $2
|
||||
AND rh.created_at <= $3
|
||||
ORDER BY rh.created_at DESC
|
||||
`
|
||||
|
||||
type GetUserReadingHistoryParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
CreatedAt_2 pgtype.Timestamptz `db:"created_at_2" json:"created_at_2"`
|
||||
}
|
||||
|
||||
type GetUserReadingHistoryRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
|
||||
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
|
||||
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
|
||||
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
||||
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
|
||||
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
DeviceName pgtype.Text `db:"device_name" json:"device_name"`
|
||||
DeviceType pgtype.Text `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// Analytics queries
|
||||
// Get user reading history for analytics
|
||||
func (q *Queries) GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetUserReadingHistory, arg.UserID, arg.CreatedAt, arg.CreatedAt_2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetUserReadingHistoryRow{}
|
||||
for rows.Next() {
|
||||
var i GetUserReadingHistoryRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.MediaItemID,
|
||||
&i.DeviceID,
|
||||
&i.ProgressPercentage,
|
||||
&i.ReadingSessionStart,
|
||||
&i.ReadingSessionEnd,
|
||||
&i.PagesRead,
|
||||
&i.TimeSpentSeconds,
|
||||
&i.DeviceMetadata,
|
||||
&i.CreatedAt,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many
|
||||
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description,
|
||||
COALESCE(lv.is_visible, true) as is_visible
|
||||
@@ -4467,67 +4702,6 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ListAllConflictsByUserAndStatus = `-- name: ListAllConflictsByUserAndStatus :many
|
||||
SELECT sc.id, sc.media_item_id, sc.user_id, sc.conflict_type, sc.conflict_data, sc.resolution_status, sc.resolution_data, sc.resolved_by, sc.resolved_at, sc.created_at, mi.title, mi.author
|
||||
FROM sync_conflicts sc
|
||||
JOIN media_items mi ON sc.media_item_id = mi.id
|
||||
WHERE sc.user_id = $1 AND sc.resolution_status = $2
|
||||
ORDER BY sc.created_at DESC
|
||||
`
|
||||
|
||||
type ListAllConflictsByUserAndStatusParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"`
|
||||
}
|
||||
|
||||
type ListAllConflictsByUserAndStatusRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
ConflictType string `db:"conflict_type" json:"conflict_type"`
|
||||
ConflictData []byte `db:"conflict_data" json:"conflict_data"`
|
||||
ResolutionStatus pgtype.Text `db:"resolution_status" json:"resolution_status"`
|
||||
ResolutionData []byte `db:"resolution_data" json:"resolution_data"`
|
||||
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
|
||||
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListAllConflictsByUserAndStatus, arg.UserID, arg.ResolutionStatus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListAllConflictsByUserAndStatusRow{}
|
||||
for rows.Next() {
|
||||
var i ListAllConflictsByUserAndStatusRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.MediaItemID,
|
||||
&i.UserID,
|
||||
&i.ConflictType,
|
||||
&i.ConflictData,
|
||||
&i.ResolutionStatus,
|
||||
&i.ResolutionData,
|
||||
&i.ResolvedBy,
|
||||
&i.ResolvedAt,
|
||||
&i.CreatedAt,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListAllSyncQueueItems = `-- name: ListAllSyncQueueItems :many
|
||||
SELECT
|
||||
sq.id, sq.device_id, sq.media_item_id, sq.sync_type, sq.sync_data, sq.priority, sq.attempts, sq.max_attempts, sq.status, sq.error_message, sq.created_at, sq.processed_at,
|
||||
@@ -5503,6 +5677,75 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.resolved = false
|
||||
ORDER BY ub.last_seen_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListUnresolvedUnlinkedBooksParams struct {
|
||||
Limit int32 `db:"limit" json:"limit"`
|
||||
Offset int32 `db:"offset" json:"offset"`
|
||||
}
|
||||
|
||||
type ListUnresolvedUnlinkedBooksRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
ContentID string `db:"content_id" json:"content_id"`
|
||||
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
||||
Title pgtype.Text `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
||||
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// List unresolved unlinked books with pagination
|
||||
func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooks, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListUnresolvedUnlinkedBooksRow{}
|
||||
for rows.Next() {
|
||||
var i ListUnresolvedUnlinkedBooksRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.DeviceID,
|
||||
&i.ContentID,
|
||||
&i.FilePath,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.ConfidenceScore,
|
||||
&i.Resolved,
|
||||
&i.MediaItemID,
|
||||
&i.ResolvedAt,
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUsers = `-- name: ListUsers :many
|
||||
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user