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:
2026-02-01 12:15:33 -05:00
parent ec5a961f6c
commit f23b6d35e9
3 changed files with 408 additions and 70 deletions
+13 -1
View File
@@ -110,6 +110,8 @@ type Querier interface {
DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error
// Delete system config // Delete system config
DeleteSystemConfig(ctx context.Context, key string) error DeleteSystemConfig(ctx context.Context, key string) error
// Delete unlinked book
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
DeleteUser(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id pgtype.UUID) error
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error) GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
// Get all system config // Get all system config
@@ -191,6 +193,8 @@ type Querier interface {
GetOpdsToken(ctx context.Context, token string) (GetOpdsTokenRow, error) GetOpdsToken(ctx context.Context, token string) (GetOpdsTokenRow, error)
// Get OPDS tokens by device // Get OPDS tokens by device
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error) GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
// Get most popular books for a user
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
// Get reading history for a user and book // Get reading history for a user and book
GetReadingHistory(ctx context.Context, arg GetReadingHistoryParams) ([]ReadingHistory, error) GetReadingHistory(ctx context.Context, arg GetReadingHistoryParams) ([]ReadingHistory, error)
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
@@ -207,16 +211,23 @@ type Querier interface {
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
// Get unlinked book by ContentId // Get unlinked book by ContentId
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
// Get unlinked book by ID
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error)
// Get unlinked books for a device // Get unlinked books for a device
GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error) GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
// Get user device usage statistics
GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error)
GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error) GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error) GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error)
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error) GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error) GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error)
// Analytics queries
// Get user reading history for analytics
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
// Check if book is in collection // Check if book is in collection
@@ -224,7 +235,6 @@ type Querier interface {
IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error) IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error)
// Link unlinked book to media item // Link unlinked book to media item
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error) ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
@@ -236,6 +246,8 @@ type Querier interface {
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error) ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
// Query media items by multiple identifiers with confidence scoring // Query media items by multiple identifiers with confidence scoring
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error) QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
+304 -61
View File
@@ -1407,6 +1407,16 @@ func (q *Queries) DeleteSystemConfig(ctx context.Context, key string) error {
return err 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 const DeleteUser = `-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1 DELETE FROM users WHERE id = $1
` `
@@ -3482,6 +3492,64 @@ func (q *Queries) GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUI
return items, nil 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 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 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 FROM reading_history
@@ -3899,6 +3967,32 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke
return i, err 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 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 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 FROM unlinked_books ub
@@ -4094,6 +4188,58 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
return i, err 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 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 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 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 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, 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 COALESCE(lv.is_visible, true) as is_visible
@@ -4467,67 +4702,6 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara
return i, err 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 const ListAllSyncQueueItems = `-- name: ListAllSyncQueueItems :many
SELECT 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, 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 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 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 SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users ORDER BY created_at DESC
` `
+91 -8
View File
@@ -627,7 +627,66 @@ INSERT INTO reading_history (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *; RETURNING *;
-- ============================================ -- Analytics queries
-- Get user reading history for analytics
-- 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;
-- Get user device usage statistics
-- 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;
-- Get most popular books for a user
-- 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;
-- ============================================
-- PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6) -- PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6)
-- ============================================ -- ============================================
@@ -818,12 +877,19 @@ RETURNING *;
-- name: DeleteSyncConflict :exec -- name: DeleteSyncConflict :exec
DELETE FROM sync_conflicts WHERE id = $1; DELETE FROM sync_conflicts WHERE id = $1;
-- name: ListAllConflictsByUserAndStatus :many -- name: ListAllConflictsByUserAndStatus :many
SELECT sc.*, mi.title, mi.author SELECT sc.*, mi.title, mi.author
FROM sync_conflicts sc FROM sync_conflicts sc
JOIN media_items mi ON sc.media_item_id = mi.id JOIN media_items mi ON sc.media_item_id = mi.id
WHERE sc.user_id = $1 AND sc.resolution_status = $2 WHERE sc.user_id = $1 AND sc.resolution_status = $2
ORDER BY sc.created_at DESC; ORDER BY sc.created_at DESC;
-- name: ListConflictsByUser :many
SELECT sc.*, mi.title, mi.author
FROM sync_conflicts sc
JOIN media_items mi ON sc.media_item_id = mi.id
WHERE sc.user_id = $1
ORDER BY sc.created_at DESC;
-- ============================================ -- ============================================
-- PHASE 3: KOREADER SYNC PROTOCOL (Weeks 7-9) -- PHASE 3: KOREADER SYNC PROTOCOL (Weeks 7-9)
@@ -1467,4 +1533,21 @@ SET
resolved_at = NOW(), resolved_at = NOW(),
resolution_method = 'manual_link' resolution_method = 'manual_link'
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
-- Get unlinked book by ID
-- name: GetUnlinkedBookByID :one
SELECT * FROM unlinked_books WHERE id = $1;
-- Delete unlinked book
-- name: DeleteUnlinkedBook :exec
DELETE FROM unlinked_books WHERE id = $1;
-- List unresolved unlinked books with pagination
-- name: ListUnresolvedUnlinkedBooks :many
SELECT ub.*, 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;