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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user