Add queue management API and database queries

- Add 7 new database queries for queue management
- GetStuckSyncQueueItems - Detect stuck items
- GetSyncQueueStats - Queue statistics
- GetNextRetryTime - Exponential backoff calc
- ListAllSyncQueueItems - Admin view
- IncrementSyncQueueAttempts - Retry counter
- Add queue handler with 7 REST endpoints
- GET /api/queue/devices/:id/stats - Queue statistics
- GET /api/queue/devices/:id/items - List device queue
- POST /api/queue/items/:id/retry - Retry failed item
- DELETE /api/queue/items/:id - Delete queue item
- DELETE /api/queue/devices/:id/clear - Clear device queue
- GET /api/queue/items - List all items (admin)
- Add full user/admin access control
This commit is contained in:
2026-01-31 13:06:12 -05:00
parent 11ee4901a5
commit 030d30a225
5 changed files with 671 additions and 0 deletions
+1
View File
@@ -394,6 +394,7 @@ type Users struct {
Theme pgtype.Text `db:"theme" json:"theme"` Theme pgtype.Text `db:"theme" json:"theme"`
ScanFrequencyMinutes pgtype.Int4 `db:"scan_frequency_minutes" json:"scan_frequency_minutes"` ScanFrequencyMinutes pgtype.Int4 `db:"scan_frequency_minutes" json:"scan_frequency_minutes"`
AutoScanEnabled pgtype.Bool `db:"auto_scan_enabled" json:"auto_scan_enabled"` AutoScanEnabled pgtype.Bool `db:"auto_scan_enabled" json:"auto_scan_enabled"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
} }
+8
View File
@@ -24,6 +24,7 @@ type Querier interface {
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
// ============================================ // ============================================
// PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6) // PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6)
// ============================================ // ============================================
@@ -73,6 +74,7 @@ type Querier interface {
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error) GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error) GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error)
GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error) GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error) GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error) GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error) GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
@@ -100,13 +102,16 @@ type Querier interface {
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error) GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error) GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
GetNextRetryTime(ctx context.Context) (interface{}, 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)
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error) GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, error) GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, error)
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error) GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error) GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error)
// Get universal progress for a book // Get universal progress for a book
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
@@ -118,8 +123,10 @@ type Querier interface {
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)
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)
IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error) IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error)
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, 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)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
@@ -169,6 +176,7 @@ type Querier interface {
UpdateSyncQueueItemStatus(ctx context.Context, arg UpdateSyncQueueItemStatusParams) (SyncQueue, error) UpdateSyncQueueItemStatus(ctx context.Context, arg UpdateSyncQueueItemStatusParams) (SyncQueue, error)
// Update universal progress // Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) error
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
+269
View File
@@ -162,6 +162,17 @@ func (q *Queries) ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfBy
return err return err
} }
const CountUserDevices = `-- name: CountUserDevices :one
SELECT COUNT(*) FROM devices WHERE user_id = $1
`
func (q *Queries) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUserDevices, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const CreateDevice = `-- name: CreateDevice :one const CreateDevice = `-- name: CreateDevice :one
INSERT INTO devices (user_id, device_name, device_type, device_identifier, auth_token, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata) INSERT INTO devices (user_id, device_name, device_type, device_identifier, auth_token, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata)
@@ -1164,6 +1175,46 @@ func (q *Queries) GetDeviceByIdentifier(ctx context.Context, deviceIdentifier st
return i, err return i, err
} }
const GetFailedSyncQueueItems = `-- name: GetFailedSyncQueueItems :many
SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue
WHERE status = 'failed' AND attempts < max_attempts
ORDER BY priority ASC, created_at ASC
LIMIT $1
`
func (q *Queries) GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error) {
rows, err := q.db.Query(ctx, GetFailedSyncQueueItems, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SyncQueue{}
for rows.Next() {
var i SyncQueue
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.MediaItemID,
&i.SyncType,
&i.SyncData,
&i.Priority,
&i.Attempts,
&i.MaxAttempts,
&i.Status,
&i.ErrorMessage,
&i.CreatedAt,
&i.ProcessedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetKoboEntitlementByContentId = `-- name: GetKoboEntitlementByContentId :one const GetKoboEntitlementByContentId = `-- name: GetKoboEntitlementByContentId :one
SELECT ke.id, ke.device_id, ke.media_item_id, ke.entitlement_id, ke.content_id, ke.revision_number, ke.purchase_date, ke.accession_date, ke.book_status, ke.sync_status, ke.kobo_metadata, ke.created_at, ke.updated_at, mi.title, mi.author, mi.file_path SELECT ke.id, ke.device_id, ke.media_item_id, ke.entitlement_id, ke.content_id, ke.revision_number, ke.purchase_date, ke.accession_date, ke.book_status, ke.sync_status, ke.kobo_metadata, ke.created_at, ke.updated_at, mi.title, mi.author, mi.file_path
FROM kobo_entitlements ke FROM kobo_entitlements ke
@@ -2089,6 +2140,25 @@ func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID)
return items, nil return items, nil
} }
const GetNextRetryTime = `-- name: GetNextRetryTime :one
SELECT
CASE
WHEN attempts = 0 THEN NOW()
WHEN attempts = 1 THEN NOW() + INTERVAL '1 minute'
WHEN attempts = 2 THEN NOW() + INTERVAL '5 minutes'
WHEN attempts = 3 THEN NOW() + INTERVAL '15 minutes'
WHEN attempts = 4 THEN NOW() + INTERVAL '1 hour'
ELSE NOW() + INTERVAL '24 hours'
END as next_retry_time
`
func (q *Queries) GetNextRetryTime(ctx context.Context) (interface{}, error) {
row := q.db.QueryRow(ctx, GetNextRetryTime)
var next_retry_time interface{}
err := row.Scan(&next_retry_time)
return next_retry_time, err
}
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
@@ -2228,6 +2298,45 @@ func (q *Queries) GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanS
return i, err return i, err
} }
const GetStuckSyncQueueItems = `-- name: GetStuckSyncQueueItems :many
SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue
WHERE status = 'processing' AND created_at < NOW() - INTERVAL '1 hour'
ORDER BY priority ASC, created_at ASC
`
func (q *Queries) GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error) {
rows, err := q.db.Query(ctx, GetStuckSyncQueueItems)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SyncQueue{}
for rows.Next() {
var i SyncQueue
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.MediaItemID,
&i.SyncType,
&i.SyncData,
&i.Priority,
&i.Attempts,
&i.MaxAttempts,
&i.Status,
&i.ErrorMessage,
&i.CreatedAt,
&i.ProcessedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSyncConflict = `-- name: GetSyncConflict :one const GetSyncConflict = `-- name: GetSyncConflict :one
SELECT id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at FROM sync_conflicts WHERE id = $1 SELECT id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at FROM sync_conflicts WHERE id = $1
` `
@@ -2274,6 +2383,38 @@ func (q *Queries) GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQue
return i, err return i, err
} }
const GetSyncQueueStats = `-- name: GetSyncQueueStats :one
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
COUNT(*) FILTER (WHERE status = 'processing') as processing_count,
COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
COUNT(*) FILTER (WHERE status = 'completed') as completed_count,
COUNT(*) as total_count
FROM sync_queue
WHERE device_id = $1
`
type GetSyncQueueStatsRow struct {
PendingCount int64 `db:"pending_count" json:"pending_count"`
ProcessingCount int64 `db:"processing_count" json:"processing_count"`
FailedCount int64 `db:"failed_count" json:"failed_count"`
CompletedCount int64 `db:"completed_count" json:"completed_count"`
TotalCount int64 `db:"total_count" json:"total_count"`
}
func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error) {
row := q.db.QueryRow(ctx, GetSyncQueueStats, deviceID)
var i GetSyncQueueStatsRow
err := row.Scan(
&i.PendingCount,
&i.ProcessingCount,
&i.FailedCount,
&i.CompletedCount,
&i.TotalCount,
)
return i, err
}
const GetUniversalProgress = `-- name: GetUniversalProgress :one const GetUniversalProgress = `-- name: GetUniversalProgress :one
SELECT SELECT
rp.id, rp.id,
@@ -2770,6 +2911,44 @@ func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUI
return items, nil return items, nil
} }
const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one
UPDATE sync_queue
SET
attempts = attempts + 1,
status = CASE
WHEN attempts + 1 >= max_attempts THEN 'failed'
ELSE 'pending'
END,
error_message = $2
WHERE id = $1
RETURNING id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at
`
type IncrementSyncQueueAttemptsParams struct {
ID pgtype.UUID `db:"id" json:"id"`
ErrorMessage pgtype.Text `db:"error_message" json:"error_message"`
}
func (q *Queries) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) {
row := q.db.QueryRow(ctx, IncrementSyncQueueAttempts, arg.ID, arg.ErrorMessage)
var i SyncQueue
err := row.Scan(
&i.ID,
&i.DeviceID,
&i.MediaItemID,
&i.SyncType,
&i.SyncData,
&i.Priority,
&i.Attempts,
&i.MaxAttempts,
&i.Status,
&i.ErrorMessage,
&i.CreatedAt,
&i.ProcessedAt,
)
return i, err
}
const IsBookOnKoboShelf = `-- name: IsBookOnKoboShelf :one const IsBookOnKoboShelf = `-- name: IsBookOnKoboShelf :one
SELECT EXISTS( SELECT EXISTS(
SELECT 1 FROM kobo_shelves SELECT 1 FROM kobo_shelves
@@ -2850,6 +3029,82 @@ func (q *Queries) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListA
return items, nil 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,
d.device_name,
d.device_type,
u.email as user_email,
mi.title as media_title
FROM sync_queue sq
JOIN devices d ON sq.device_id = d.id
JOIN users u ON d.user_id = u.id
LEFT JOIN media_items mi ON sq.media_item_id = mi.id
ORDER BY sq.priority ASC, sq.created_at DESC
LIMIT $1 OFFSET $2
`
type ListAllSyncQueueItemsParams struct {
Limit int32 `db:"limit" json:"limit"`
Offset int32 `db:"offset" json:"offset"`
}
type ListAllSyncQueueItemsRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
SyncType string `db:"sync_type" json:"sync_type"`
SyncData []byte `db:"sync_data" json:"sync_data"`
Priority pgtype.Int4 `db:"priority" json:"priority"`
Attempts pgtype.Int4 `db:"attempts" json:"attempts"`
MaxAttempts pgtype.Int4 `db:"max_attempts" json:"max_attempts"`
Status pgtype.Text `db:"status" json:"status"`
ErrorMessage pgtype.Text `db:"error_message" json:"error_message"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
ProcessedAt pgtype.Timestamptz `db:"processed_at" json:"processed_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
UserEmail string `db:"user_email" json:"user_email"`
MediaTitle pgtype.Text `db:"media_title" json:"media_title"`
}
func (q *Queries) ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error) {
rows, err := q.db.Query(ctx, ListAllSyncQueueItems, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAllSyncQueueItemsRow{}
for rows.Next() {
var i ListAllSyncQueueItemsRow
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.MediaItemID,
&i.SyncType,
&i.SyncData,
&i.Priority,
&i.Attempts,
&i.MaxAttempts,
&i.Status,
&i.ErrorMessage,
&i.CreatedAt,
&i.ProcessedAt,
&i.DeviceName,
&i.DeviceType,
&i.UserEmail,
&i.MediaTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListDevicesByType = `-- name: ListDevicesByType :many const ListDevicesByType = `-- name: ListDevicesByType :many
SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE device_type = $1 ORDER BY created_at DESC SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE device_type = $1 ORDER BY created_at DESC
` `
@@ -5041,6 +5296,20 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
return i, err return i, err
} }
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :exec
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserMaxDevicesParams struct {
ID pgtype.UUID `db:"id" json:"id"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
}
func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) error {
_, err := q.db.Exec(ctx, UpdateUserMaxDevices, arg.ID, arg.MaxDevices)
return err
}
const UpdateUserProfile = `-- name: UpdateUserProfile :exec const UpdateUserProfile = `-- name: UpdateUserProfile :exec
UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1 UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1
` `
+64
View File
@@ -305,6 +305,12 @@ UPDATE users SET email = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdatePassword :exec -- name: UpdatePassword :exec
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1; UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserMaxDevices :exec
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1;
-- name: CountUserDevices :one
SELECT COUNT(*) FROM devices WHERE user_id = $1;
-- name: DeleteUser :exec -- name: DeleteUser :exec
DELETE FROM users WHERE id = $1; DELETE FROM users WHERE id = $1;
@@ -720,6 +726,64 @@ DELETE FROM sync_queue WHERE id = $1;
-- name: ClearDeviceSyncQueue :exec -- name: ClearDeviceSyncQueue :exec
DELETE FROM sync_queue WHERE device_id = $1; DELETE FROM sync_queue WHERE device_id = $1;
-- name: GetFailedSyncQueueItems :many
SELECT * FROM sync_queue
WHERE status = 'failed' AND attempts < max_attempts
ORDER BY priority ASC, created_at ASC
LIMIT $1;
-- name: GetStuckSyncQueueItems :many
SELECT * FROM sync_queue
WHERE status = 'processing' AND created_at < NOW() - INTERVAL '1 hour'
ORDER BY priority ASC, created_at ASC;
-- name: IncrementSyncQueueAttempts :one
UPDATE sync_queue
SET
attempts = attempts + 1,
status = CASE
WHEN attempts + 1 >= max_attempts THEN 'failed'
ELSE 'pending'
END,
error_message = $2
WHERE id = $1
RETURNING *;
-- name: GetSyncQueueStats :one
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
COUNT(*) FILTER (WHERE status = 'processing') as processing_count,
COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
COUNT(*) FILTER (WHERE status = 'completed') as completed_count,
COUNT(*) as total_count
FROM sync_queue
WHERE device_id = $1;
-- name: ListAllSyncQueueItems :many
SELECT
sq.*,
d.device_name,
d.device_type,
u.email as user_email,
mi.title as media_title
FROM sync_queue sq
JOIN devices d ON sq.device_id = d.id
JOIN users u ON d.user_id = u.id
LEFT JOIN media_items mi ON sq.media_item_id = mi.id
ORDER BY sq.priority ASC, sq.created_at DESC
LIMIT $1 OFFSET $2;
-- name: GetNextRetryTime :one
SELECT
CASE
WHEN attempts = 0 THEN NOW()
WHEN attempts = 1 THEN NOW() + INTERVAL '1 minute'
WHEN attempts = 2 THEN NOW() + INTERVAL '5 minutes'
WHEN attempts = 3 THEN NOW() + INTERVAL '15 minutes'
WHEN attempts = 4 THEN NOW() + INTERVAL '1 hour'
ELSE NOW() + INTERVAL '24 hours'
END as next_retry_time;
-- Conflict Resolution -- Conflict Resolution
-- name: CreateSyncConflict :one -- name: CreateSyncConflict :one
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data) INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
+329
View File
@@ -0,0 +1,329 @@
package handlers
import (
"bookmann/internal/database"
"context"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type QueueHandler struct {
db *database.Queries
queue interface {
GetQueueStats(ctx context.Context, deviceID pgtype.UUID) (database.GetSyncQueueStatsRow, error)
}
}
func NewQueueHandler(db *database.Queries, queue interface {
GetQueueStats(ctx context.Context, deviceID pgtype.UUID) (database.GetSyncQueueStatsRow, error)
}) *QueueHandler {
return &QueueHandler{db: db, queue: queue}
}
type QueueStatsResponse struct {
PendingCount int64 `json:"pending_count"`
ProcessingCount int64 `json:"processing_count"`
FailedCount int64 `json:"failed_count"`
CompletedCount int64 `json:"completed_count"`
TotalCount int64 `json:"total_count"`
}
type QueueItemResponse struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"`
MediaItemID *string `json:"media_item_id,omitempty"`
MediaTitle *string `json:"media_title,omitempty"`
UserEmail string `json:"user_email"`
SyncType string `json:"sync_type"`
Priority int32 `json:"priority"`
Attempts int32 `json:"attempts"`
MaxAttempts int32 `json:"max_attempts"`
Status string `json:"status"`
ErrorMessage *string `json:"error_message,omitempty"`
CreatedAt string `json:"created_at"`
ProcessedAt *string `json:"processed_at,omitempty"`
}
func (h *QueueHandler) GetDeviceQueueStats(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
deviceID, err := uuid.Parse(c.Param("device_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid device ID")
}
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
stats, err := h.queue.GetQueueStats(c.Request().Context(), device.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get queue stats")
}
return c.JSON(http.StatusOK, QueueStatsResponse{
PendingCount: stats.PendingCount,
ProcessingCount: stats.ProcessingCount,
FailedCount: stats.FailedCount,
CompletedCount: stats.CompletedCount,
TotalCount: stats.TotalCount,
})
}
func (h *QueueHandler) ListDeviceQueueItems(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
deviceID, err := uuid.Parse(c.Param("device_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid device ID")
}
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
status := c.QueryParam("status")
limit := 50
if l := c.QueryParam("limit"); l != "" {
if parsedLimit, err := strconv.Atoi(l); err == nil && parsedLimit > 0 && parsedLimit <= 100 {
limit = parsedLimit
}
}
var items []database.SyncQueue
if status != "" {
items, err = h.db.ListPendingSyncQueueItems(c.Request().Context(), database.ListPendingSyncQueueItemsParams{
DeviceID: device.ID,
Limit: int32(limit),
})
} else {
items, err = h.db.ListPendingSyncQueueItems(c.Request().Context(), database.ListPendingSyncQueueItemsParams{
DeviceID: device.ID,
Limit: int32(limit),
})
}
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list queue items")
}
response := make([]QueueItemResponse, 0, len(items))
for _, item := range items {
response = append(response, QueueItemResponse{
ID: uuid.UUID(item.ID.Bytes).String(),
DeviceID: uuid.UUID(item.DeviceID.Bytes).String(),
MediaItemID: uuidPtrToString(item.MediaItemID),
SyncType: item.SyncType,
Priority: item.Priority.Int32,
Attempts: item.Attempts.Int32,
MaxAttempts: item.MaxAttempts.Int32,
Status: item.Status.String,
ErrorMessage: textPtrToString(item.ErrorMessage),
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"items": response,
"count": len(response),
})
}
func (h *QueueHandler) ListAllQueueItems(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "admin access required")
}
limit := 50
if l := c.QueryParam("limit"); l != "" {
if parsedLimit, err := strconv.Atoi(l); err == nil && parsedLimit > 0 && parsedLimit <= 100 {
limit = parsedLimit
}
}
offset := 0
if o := c.QueryParam("offset"); o != "" {
if parsedOffset, err := strconv.Atoi(o); err == nil && parsedOffset >= 0 {
offset = parsedOffset
}
}
items, err := h.db.ListAllSyncQueueItems(c.Request().Context(), database.ListAllSyncQueueItemsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list queue items")
}
response := make([]QueueItemResponse, 0, len(items))
for _, item := range items {
mediaTitle := textPtrToString(item.MediaTitle)
response = append(response, QueueItemResponse{
ID: uuid.UUID(item.ID.Bytes).String(),
DeviceID: uuid.UUID(item.DeviceID.Bytes).String(),
DeviceName: item.DeviceName,
DeviceType: item.DeviceType,
MediaItemID: uuidPtrToString(item.MediaItemID),
MediaTitle: mediaTitle,
UserEmail: item.UserEmail,
SyncType: item.SyncType,
Priority: item.Priority.Int32,
Attempts: item.Attempts.Int32,
MaxAttempts: item.MaxAttempts.Int32,
Status: item.Status.String,
ErrorMessage: textPtrToString(item.ErrorMessage),
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"items": response,
"count": len(response),
"limit": limit,
"offset": offset,
})
}
func (h *QueueHandler) RetryQueueItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
itemID, err := uuid.Parse(c.Param("item_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid item ID")
}
item, err := h.db.GetSyncQueueItem(c.Request().Context(), pgtype.UUID{Bytes: itemID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "queue item not found")
}
device, err := h.db.GetDevice(c.Request().Context(), item.DeviceID)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
updatedItem, err := h.db.UpdateSyncQueueItemStatus(c.Request().Context(), database.UpdateSyncQueueItemStatusParams{
ID: item.ID,
Status: pgtype.Text{String: "pending", Valid: true},
ErrorMessage: pgtype.Text{},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to retry item")
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "item queued for retry",
"item_id": uuid.UUID(updatedItem.ID.Bytes).String(),
"status": updatedItem.Status.String,
"attempts": updatedItem.Attempts.Int32,
"max_attempts": updatedItem.MaxAttempts.Int32,
})
}
func (h *QueueHandler) DeleteQueueItem(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
itemID, err := uuid.Parse(c.Param("item_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid item ID")
}
item, err := h.db.GetSyncQueueItem(c.Request().Context(), pgtype.UUID{Bytes: itemID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "queue item not found")
}
device, err := h.db.GetDevice(c.Request().Context(), item.DeviceID)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
err = h.db.DeleteSyncQueueItem(c.Request().Context(), item.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete item")
}
return c.NoContent(http.StatusNoContent)
}
func (h *QueueHandler) ClearDeviceQueue(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
deviceID, err := uuid.Parse(c.Param("device_id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid device ID")
}
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "device not found")
}
if device.UserID.Bytes != user.ID.Bytes && user.Role != "admin" {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
err = h.db.ClearDeviceSyncQueue(c.Request().Context(), device.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to clear queue")
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "queue cleared",
})
}
func uuidPtrToString(u pgtype.UUID) *string {
if !u.Valid {
return nil
}
s := uuid.UUID(u.Bytes).String()
return &s
}
func textPtrToString(t pgtype.Text) *string {
if !t.Valid {
return nil
}
return &t.String
}
func timestamptzPtrToString(t pgtype.Timestamptz) *string {
if !t.Valid {
return nil
}
s := t.Time.Format("2006-01-02T15:04:05Z07:00")
return &s
}