Phase 2 Week 5: Device Registration & Management
Implement device registration and management system for universal sync. Database Changes: - Add device queries to queries.sql (CRUD operations, registration, auth) - Add sync queue management queries - Add conflict resolution queries - Regenerate sqlc models with new device-related types Device Handler (devices.go): - InitiateRegistration: Start device registration with auth URL and QR code - CheckRegistrationStatus: Poll for registration approval - ListDevices: Get all devices for current user - GetDevice: Get specific device details - UpdateDevice: Update device settings (name, sync settings, frequency) - DeleteDevice: Remove device from account - ApproveDevice: User approves device registration via web - RejectDevice: Reject pending device registration - ListPendingRegistrations: Show all pending registrations - generateDeviceToken: Generate secure Bearer token for devices Device Authentication Middleware (device_auth.go): - Authenticate: Validate device Bearer tokens - RequirePermission: Check device permissions by type - hasPermission: Define permissions per device type - UpdateLastSeen: Auto-update device last_seen timestamp Configuration: - Add BaseURL field to Config for device setup URLs API Endpoints: POST /api/devices/register - Initiate device registration POST /api/devices/register/status - Check registration status GET /api/devices/approve/:id - Approve device (web UI) POST /api/devices/reject/:id - Reject device GET /api/devices - List user's devices GET /api/devices/:id - Get device details PUT /api/devices/:id - Update device settings DELETE /api/devices/:id - Delete device GET /api/devices/pending - List pending registrations Bruno API Collection: - Initiate Device Registration - Check Registration Status - List Devices - Get Device - Update Device - Delete Device Dependencies: - github.com/skip2/go-qrcode for QR code generation Device Types Supported: - koreader: Calibre-compatible sync - kobo: Kobo sync protocol - web: Web interface - mobile: Mobile apps Device Permissions: - sync:progress - sync:annotations - sync:metadata - device:manage (web only)
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
meta {
|
||||||
|
name: Check Registration Status
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/api/devices/register/status
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"registration_id": "{{registrationId}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: Delete Device
|
||||||
|
type: http
|
||||||
|
seq: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
url: {{baseUrl}}/api/devices/{{deviceId}}
|
||||||
|
body: none
|
||||||
|
auth: bearer
|
||||||
|
}
|
||||||
|
|
||||||
|
headers: {
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Device
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/api/devices/{{deviceId}}
|
||||||
|
body: none
|
||||||
|
auth: bearer
|
||||||
|
}
|
||||||
|
|
||||||
|
headers: {
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
meta {
|
||||||
|
name: Initiate Device Registration
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/api/devices/register
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"device_name": "My Kindle Paperwhite",
|
||||||
|
"device_type": "koreader",
|
||||||
|
"device_identifier": "kindle-pw5-hardware-id-12345"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: List Devices
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/api/devices
|
||||||
|
body: none
|
||||||
|
auth: bearer
|
||||||
|
}
|
||||||
|
|
||||||
|
headers: {
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
meta {
|
||||||
|
name: Update Device
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: {{baseUrl}}/api/devices/{{deviceId}}
|
||||||
|
body: json
|
||||||
|
auth: bearer
|
||||||
|
}
|
||||||
|
|
||||||
|
headers: {
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"device_name": "My Updated Kindle",
|
||||||
|
"sync_enabled": true,
|
||||||
|
"auto_sync": true,
|
||||||
|
"sync_frequency_minutes": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookmann/internal/config"
|
"bookmann/internal/config"
|
||||||
"bookmann/internal/database"
|
"bookmann/internal/database"
|
||||||
"bookmann/internal/handlers"
|
"bookmann/internal/handlers"
|
||||||
|
"bookmann/internal/middleware"
|
||||||
ratelimit "bookmann/internal/middleware"
|
ratelimit "bookmann/internal/middleware"
|
||||||
"bookmann/templates"
|
"bookmann/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -48,6 +49,10 @@ func main() {
|
|||||||
|
|
||||||
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
||||||
libraryHandler := handlers.NewLibraryHandler(queries)
|
libraryHandler := handlers.NewLibraryHandler(queries)
|
||||||
|
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
||||||
|
|
||||||
|
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||||||
|
_ = deviceAuthMiddleware
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
@@ -151,6 +156,20 @@ func main() {
|
|||||||
authGroup.PUT("/theme", authHandler.UpdateTheme)
|
authGroup.PUT("/theme", authHandler.UpdateTheme)
|
||||||
// force rebuild
|
// force rebuild
|
||||||
|
|
||||||
|
// Device management routes (public - for registration)
|
||||||
|
e.POST("/api/devices/register", deviceHandler.InitiateRegistration)
|
||||||
|
e.POST("/api/devices/register/status", deviceHandler.CheckRegistrationStatus)
|
||||||
|
e.GET("/devices/approve/:registration_id", deviceHandler.ApproveDevice)
|
||||||
|
e.POST("/devices/reject/:registration_id", deviceHandler.RejectDevice)
|
||||||
|
|
||||||
|
// Device management routes (protected - require user auth)
|
||||||
|
devices := protected.Group("/devices")
|
||||||
|
devices.GET("", deviceHandler.ListDevices)
|
||||||
|
devices.GET("/:id", deviceHandler.GetDevice)
|
||||||
|
devices.PUT("/:id", deviceHandler.UpdateDevice)
|
||||||
|
devices.DELETE("/:id", deviceHandler.DeleteDevice)
|
||||||
|
devices.GET("/pending", deviceHandler.ListPendingRegistrations)
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
e.Static("/static", "web/static")
|
e.Static("/static", "web/static")
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.4.3
|
github.com/jackc/pgx/v5 v5.4.3
|
||||||
github.com/labstack/echo-jwt/v4 v4.4.0
|
github.com/labstack/echo-jwt/v4 v4.4.0
|
||||||
github.com/labstack/echo/v4 v4.13.4
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.46.0
|
golang.org/x/crypto v0.46.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServerPort string
|
ServerPort string
|
||||||
|
BaseURL string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
UploadPath string
|
UploadPath string
|
||||||
DatabaseHost string
|
DatabaseHost string
|
||||||
@@ -23,6 +24,7 @@ type Config struct {
|
|||||||
func LoadConfig() *Config {
|
func LoadConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||||
|
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
|
||||||
DatabaseHost: getEnv("DATABASE_HOST", "localhost"),
|
DatabaseHost: getEnv("DATABASE_HOST", "localhost"),
|
||||||
DatabasePort: getEnv("DATABASE_PORT", "5432"),
|
DatabasePort: getEnv("DATABASE_PORT", "5432"),
|
||||||
DatabaseUser: getEnv("DATABASE_USER", "postgres"),
|
DatabaseUser: getEnv("DATABASE_USER", "postgres"),
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ type Querier interface {
|
|||||||
// Bulk update format group for all media items
|
// Bulk update format group for all media items
|
||||||
BulkUpdateFormatGroups(ctx context.Context) error
|
BulkUpdateFormatGroups(ctx context.Context) error
|
||||||
CleanupExpiredRefreshTokens(ctx context.Context) error
|
CleanupExpiredRefreshTokens(ctx context.Context) error
|
||||||
|
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
|
// ============================================
|
||||||
|
// PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6)
|
||||||
|
// ============================================
|
||||||
|
// Device Registration & Management
|
||||||
|
CreateDevice(ctx context.Context, arg CreateDeviceParams) (Devices, error)
|
||||||
// Backward compatibility - Ebook Notes queries (using views)
|
// Backward compatibility - Ebook Notes queries (using views)
|
||||||
CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error)
|
CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error)
|
||||||
// Libraries queries
|
// Libraries queries
|
||||||
@@ -31,7 +37,13 @@ type Querier interface {
|
|||||||
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
|
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
|
||||||
// Refresh Tokens queries
|
// Refresh Tokens queries
|
||||||
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error)
|
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error)
|
||||||
|
// Conflict Resolution
|
||||||
|
CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error)
|
||||||
|
// Sync Queue Management
|
||||||
|
CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error)
|
||||||
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
|
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
|
||||||
|
DeleteDevice(ctx context.Context, id pgtype.UUID) error
|
||||||
|
DeleteDeviceByToken(ctx context.Context, authToken string) error
|
||||||
DeleteEbookNote(ctx context.Context, id pgtype.UUID) error
|
DeleteEbookNote(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteLibrary(ctx context.Context, id pgtype.UUID) error
|
DeleteLibrary(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error)
|
DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error)
|
||||||
@@ -40,7 +52,12 @@ type Querier interface {
|
|||||||
DeleteMediaNote(ctx context.Context, id pgtype.UUID) error
|
DeleteMediaNote(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
||||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||||
|
DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error
|
||||||
|
DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
|
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
|
||||||
|
GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error)
|
||||||
|
GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error)
|
||||||
GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
|
GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
|
||||||
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error)
|
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error)
|
||||||
GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
|
GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
|
||||||
@@ -62,6 +79,8 @@ type Querier interface {
|
|||||||
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
|
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
|
||||||
GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error)
|
GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error)
|
||||||
GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, error)
|
GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, error)
|
||||||
|
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
|
||||||
|
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, 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)
|
||||||
@@ -71,13 +90,20 @@ type Querier interface {
|
|||||||
GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
|
GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
|
||||||
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
|
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
|
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||||
|
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||||
ListMediaItemsFiltered(ctx context.Context, arg ListMediaItemsFilteredParams) ([]ListMediaItemsFilteredRow, error)
|
ListMediaItemsFiltered(ctx context.Context, arg ListMediaItemsFilteredParams) ([]ListMediaItemsFilteredRow, error)
|
||||||
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
||||||
|
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||||
|
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||||
|
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
|
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
|
||||||
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
|
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
|
||||||
|
RevokeDevice(ctx context.Context, id pgtype.UUID) error
|
||||||
RevokeRefreshToken(ctx context.Context, token string) error
|
RevokeRefreshToken(ctx context.Context, token string) error
|
||||||
// Note: User ebook folders replaced by library folders system
|
// Note: User ebook folders replaced by library folders system
|
||||||
// Legacy folder management is now handled through libraries
|
// Legacy folder management is now handled through libraries
|
||||||
@@ -86,6 +112,9 @@ type Querier interface {
|
|||||||
SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItemsFuzzyParams) ([]SearchMediaItemsFuzzyRow, error)
|
SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItemsFuzzyParams) ([]SearchMediaItemsFuzzyRow, error)
|
||||||
// Library Visibility queries
|
// Library Visibility queries
|
||||||
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||||
|
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||||
|
UpdateDeviceLastSeen(ctx context.Context, id pgtype.UUID) (Devices, error)
|
||||||
|
UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Devices, error)
|
||||||
UpdateEbookNote(ctx context.Context, arg UpdateEbookNoteParams) (MediaNotes, error)
|
UpdateEbookNote(ctx context.Context, arg UpdateEbookNoteParams) (MediaNotes, error)
|
||||||
UpdateEmail(ctx context.Context, arg UpdateEmailParams) error
|
UpdateEmail(ctx context.Context, arg UpdateEmailParams) error
|
||||||
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
||||||
@@ -101,6 +130,7 @@ type Querier interface {
|
|||||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||||
UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) error
|
UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) 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)
|
||||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||||
|
|||||||
@@ -59,6 +59,70 @@ func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ClearDeviceSyncQueue = `-- name: ClearDeviceSyncQueue :exec
|
||||||
|
DELETE FROM sync_queue WHERE device_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, ClearDeviceSyncQueue, deviceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING 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
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateDeviceParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
DeviceName string `db:"device_name" json:"device_name"`
|
||||||
|
DeviceType string `db:"device_type" json:"device_type"`
|
||||||
|
DeviceIdentifier string `db:"device_identifier" json:"device_identifier"`
|
||||||
|
AuthToken string `db:"auth_token" json:"auth_token"`
|
||||||
|
SyncEnabled pgtype.Bool `db:"sync_enabled" json:"sync_enabled"`
|
||||||
|
AutoSync pgtype.Bool `db:"auto_sync" json:"auto_sync"`
|
||||||
|
SyncFrequencyMinutes pgtype.Int4 `db:"sync_frequency_minutes" json:"sync_frequency_minutes"`
|
||||||
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6)
|
||||||
|
// ============================================
|
||||||
|
// Device Registration & Management
|
||||||
|
func (q *Queries) CreateDevice(ctx context.Context, arg CreateDeviceParams) (Devices, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CreateDevice,
|
||||||
|
arg.UserID,
|
||||||
|
arg.DeviceName,
|
||||||
|
arg.DeviceType,
|
||||||
|
arg.DeviceIdentifier,
|
||||||
|
arg.AuthToken,
|
||||||
|
arg.SyncEnabled,
|
||||||
|
arg.AutoSync,
|
||||||
|
arg.SyncFrequencyMinutes,
|
||||||
|
arg.DeviceMetadata,
|
||||||
|
)
|
||||||
|
var i Devices
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const CreateEbookNote = `-- name: CreateEbookNote :one
|
const CreateEbookNote = `-- name: CreateEbookNote :one
|
||||||
INSERT INTO media_notes (media_item_id, user_id, content, position)
|
INSERT INTO media_notes (media_item_id, user_id, content, position)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
@@ -447,6 +511,88 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CreateSyncConflict = `-- name: CreateSyncConflict :one
|
||||||
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateSyncConflictParams struct {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict Resolution
|
||||||
|
func (q *Queries) CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CreateSyncConflict,
|
||||||
|
arg.MediaItemID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.ConflictType,
|
||||||
|
arg.ConflictData,
|
||||||
|
)
|
||||||
|
var i SyncConflicts
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.MediaItemID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.ConflictType,
|
||||||
|
&i.ConflictData,
|
||||||
|
&i.ResolutionStatus,
|
||||||
|
&i.ResolutionData,
|
||||||
|
&i.ResolvedBy,
|
||||||
|
&i.ResolvedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateSyncQueueItem = `-- name: CreateSyncQueueItem :one
|
||||||
|
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, max_attempts, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateSyncQueueItemParams struct {
|
||||||
|
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"`
|
||||||
|
MaxAttempts pgtype.Int4 `db:"max_attempts" json:"max_attempts"`
|
||||||
|
Status pgtype.Text `db:"status" json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync Queue Management
|
||||||
|
func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CreateSyncQueueItem,
|
||||||
|
arg.DeviceID,
|
||||||
|
arg.MediaItemID,
|
||||||
|
arg.SyncType,
|
||||||
|
arg.SyncData,
|
||||||
|
arg.Priority,
|
||||||
|
arg.MaxAttempts,
|
||||||
|
arg.Status,
|
||||||
|
)
|
||||||
|
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 CreateUser = `-- name: CreateUser :one
|
const CreateUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role)
|
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
@@ -500,6 +646,24 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DeleteDevice = `-- name: DeleteDevice :exec
|
||||||
|
DELETE FROM devices WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteDevice(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, DeleteDevice, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteDeviceByToken = `-- name: DeleteDeviceByToken :exec
|
||||||
|
DELETE FROM devices WHERE auth_token = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteDeviceByToken(ctx context.Context, authToken string) error {
|
||||||
|
_, err := q.db.Exec(ctx, DeleteDeviceByToken, authToken)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const DeleteEbookNote = `-- name: DeleteEbookNote :exec
|
const DeleteEbookNote = `-- name: DeleteEbookNote :exec
|
||||||
DELETE FROM media_notes WHERE id = $1
|
DELETE FROM media_notes WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -594,6 +758,24 @@ func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingPr
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DeleteSyncConflict = `-- name: DeleteSyncConflict :exec
|
||||||
|
DELETE FROM sync_conflicts WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, DeleteSyncConflict, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteSyncQueueItem = `-- name: DeleteSyncQueueItem :exec
|
||||||
|
DELETE FROM sync_queue WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, DeleteSyncQueueItem, 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
|
||||||
`
|
`
|
||||||
@@ -603,6 +785,84 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetDevice = `-- name: GetDevice :one
|
||||||
|
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 id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetDevice, id)
|
||||||
|
var i Devices
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetDeviceByAuthToken = `-- name: GetDeviceByAuthToken :one
|
||||||
|
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 auth_token = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetDeviceByAuthToken, authToken)
|
||||||
|
var i Devices
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetDeviceByIdentifier = `-- name: GetDeviceByIdentifier :one
|
||||||
|
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_identifier = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetDeviceByIdentifier, deviceIdentifier)
|
||||||
|
var i Devices
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const GetLibrary = `-- name: GetLibrary :one
|
const GetLibrary = `-- name: GetLibrary :one
|
||||||
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
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
@@ -1243,6 +1503,52 @@ func (q *Queries) GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanS
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetSyncConflict, id)
|
||||||
|
var i SyncConflicts
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.MediaItemID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.ConflictType,
|
||||||
|
&i.ConflictData,
|
||||||
|
&i.ResolutionStatus,
|
||||||
|
&i.ResolutionData,
|
||||||
|
&i.ResolvedBy,
|
||||||
|
&i.ResolvedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetSyncQueueItem = `-- name: GetSyncQueueItem :one
|
||||||
|
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 id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetSyncQueueItem, id)
|
||||||
|
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 GetUniversalProgress = `-- name: GetUniversalProgress :one
|
const GetUniversalProgress = `-- name: GetUniversalProgress :one
|
||||||
SELECT
|
SELECT
|
||||||
rp.id,
|
rp.id,
|
||||||
@@ -1586,6 +1892,84 @@ func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUI
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListDevicesByType, deviceType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Devices{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Devices
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListDevicesByUser = `-- name: ListDevicesByUser :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 user_id = $1 ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListDevicesByUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Devices{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Devices
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const ListLibraries = `-- name: ListLibraries :many
|
const ListLibraries = `-- name: ListLibraries :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
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
@@ -2200,6 +2584,149 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ListPendingSyncQueueItems = `-- name: ListPendingSyncQueueItems :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 device_id = $1 AND status = 'pending'
|
||||||
|
ORDER BY priority ASC, created_at ASC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListPendingSyncQueueItemsParams struct {
|
||||||
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||||
|
Limit int32 `db:"limit" json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListPendingSyncQueueItems, arg.DeviceID, arg.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 ListSyncConflictsByMediaItem = `-- name: ListSyncConflictsByMediaItem :many
|
||||||
|
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 media_item_id = $1 AND user_id = $2
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListSyncConflictsByMediaItemParams struct {
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListSyncConflictsByMediaItem, arg.MediaItemID, arg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SyncConflicts{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i SyncConflicts
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.MediaItemID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.ConflictType,
|
||||||
|
&i.ConflictData,
|
||||||
|
&i.ResolutionStatus,
|
||||||
|
&i.ResolutionData,
|
||||||
|
&i.ResolvedBy,
|
||||||
|
&i.ResolvedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListSyncConflictsByUser = `-- name: ListSyncConflictsByUser :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 = 'unresolved'
|
||||||
|
ORDER BY sc.created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListSyncConflictsByUserRow 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) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListSyncConflictsByUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []ListSyncConflictsByUserRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListSyncConflictsByUserRow
|
||||||
|
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 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
|
||||||
`
|
`
|
||||||
@@ -2246,6 +2773,47 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ResolveSyncConflict = `-- name: ResolveSyncConflict :one
|
||||||
|
UPDATE sync_conflicts
|
||||||
|
SET
|
||||||
|
resolution_status = $2,
|
||||||
|
resolution_data = $3,
|
||||||
|
resolved_by = $4,
|
||||||
|
resolved_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type ResolveSyncConflictParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error) {
|
||||||
|
row := q.db.QueryRow(ctx, ResolveSyncConflict,
|
||||||
|
arg.ID,
|
||||||
|
arg.ResolutionStatus,
|
||||||
|
arg.ResolutionData,
|
||||||
|
arg.ResolvedBy,
|
||||||
|
)
|
||||||
|
var i SyncConflicts
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.MediaItemID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.ConflictType,
|
||||||
|
&i.ConflictData,
|
||||||
|
&i.ResolutionStatus,
|
||||||
|
&i.ResolutionData,
|
||||||
|
&i.ResolvedBy,
|
||||||
|
&i.ResolvedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
|
const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
|
||||||
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL
|
||||||
`
|
`
|
||||||
@@ -2255,6 +2823,20 @@ func (q *Queries) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RevokeDevice = `-- name: RevokeDevice :exec
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
auth_token = NULL,
|
||||||
|
sync_enabled = false,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) RevokeDevice(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, RevokeDevice, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec
|
const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec
|
||||||
UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1
|
||||||
`
|
`
|
||||||
@@ -2567,6 +3149,120 @@ func (q *Queries) SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibi
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpdateDevice = `-- name: UpdateDevice :one
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
device_name = $2,
|
||||||
|
sync_enabled = $3,
|
||||||
|
auto_sync = $4,
|
||||||
|
sync_frequency_minutes = $5,
|
||||||
|
device_metadata = $6,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING 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
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateDeviceParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
DeviceName string `db:"device_name" json:"device_name"`
|
||||||
|
SyncEnabled pgtype.Bool `db:"sync_enabled" json:"sync_enabled"`
|
||||||
|
AutoSync pgtype.Bool `db:"auto_sync" json:"auto_sync"`
|
||||||
|
SyncFrequencyMinutes pgtype.Int4 `db:"sync_frequency_minutes" json:"sync_frequency_minutes"`
|
||||||
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateDevice,
|
||||||
|
arg.ID,
|
||||||
|
arg.DeviceName,
|
||||||
|
arg.SyncEnabled,
|
||||||
|
arg.AutoSync,
|
||||||
|
arg.SyncFrequencyMinutes,
|
||||||
|
arg.DeviceMetadata,
|
||||||
|
)
|
||||||
|
var i Devices
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateDeviceLastSeen = `-- name: UpdateDeviceLastSeen :one
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
last_seen = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING 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
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) UpdateDeviceLastSeen(ctx context.Context, id pgtype.UUID) (Devices, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateDeviceLastSeen, id)
|
||||||
|
var i Devices
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateDeviceLastSync = `-- name: UpdateDeviceLastSync :one
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
last_sync = NOW(),
|
||||||
|
last_seen = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING 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
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Devices, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateDeviceLastSync, id)
|
||||||
|
var i Devices
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.DeviceType,
|
||||||
|
&i.DeviceIdentifier,
|
||||||
|
&i.AuthToken,
|
||||||
|
&i.LastSync,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.SyncEnabled,
|
||||||
|
&i.AutoSync,
|
||||||
|
&i.SyncFrequencyMinutes,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateEbookNote = `-- name: UpdateEbookNote :one
|
const UpdateEbookNote = `-- name: UpdateEbookNote :one
|
||||||
UPDATE media_notes SET
|
UPDATE media_notes SET
|
||||||
content = $2,
|
content = $2,
|
||||||
@@ -3009,6 +3705,43 @@ func (q *Queries) UpdateScanSettings(ctx context.Context, arg UpdateScanSettings
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpdateSyncQueueItemStatus = `-- name: UpdateSyncQueueItemStatus :one
|
||||||
|
UPDATE sync_queue
|
||||||
|
SET
|
||||||
|
status = $2,
|
||||||
|
attempts = attempts + 1,
|
||||||
|
error_message = $3,
|
||||||
|
processed_at = CASE WHEN $2 = 'completed' THEN NOW() ELSE NULL END
|
||||||
|
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 UpdateSyncQueueItemStatusParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
Status pgtype.Text `db:"status" json:"status"`
|
||||||
|
ErrorMessage pgtype.Text `db:"error_message" json:"error_message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateSyncQueueItemStatus(ctx context.Context, arg UpdateSyncQueueItemStatusParams) (SyncQueue, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateSyncQueueItemStatus, arg.ID, arg.Status, 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 UpdateUniversalProgress = `-- name: UpdateUniversalProgress :one
|
const UpdateUniversalProgress = `-- name: UpdateUniversalProgress :one
|
||||||
INSERT INTO reading_progress (
|
INSERT INTO reading_progress (
|
||||||
media_item_id,
|
media_item_id,
|
||||||
|
|||||||
@@ -621,4 +621,137 @@ 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 *;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Device Registration & Management
|
||||||
|
-- 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)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetDevice :one
|
||||||
|
SELECT * FROM devices WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetDeviceByIdentifier :one
|
||||||
|
SELECT * FROM devices WHERE device_identifier = $1;
|
||||||
|
|
||||||
|
-- name: GetDeviceByAuthToken :one
|
||||||
|
SELECT * FROM devices WHERE auth_token = $1;
|
||||||
|
|
||||||
|
-- name: ListDevicesByUser :many
|
||||||
|
SELECT * FROM devices WHERE user_id = $1 ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: ListDevicesByType :many
|
||||||
|
SELECT * FROM devices WHERE device_type = $1 ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: UpdateDevice :one
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
device_name = $2,
|
||||||
|
sync_enabled = $3,
|
||||||
|
auto_sync = $4,
|
||||||
|
sync_frequency_minutes = $5,
|
||||||
|
device_metadata = $6,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateDeviceLastSync :one
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
last_sync = NOW(),
|
||||||
|
last_seen = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateDeviceLastSeen :one
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
last_seen = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: RevokeDevice :exec
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
auth_token = NULL,
|
||||||
|
sync_enabled = false,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteDevice :exec
|
||||||
|
DELETE FROM devices WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteDeviceByToken :exec
|
||||||
|
DELETE FROM devices WHERE auth_token = $1;
|
||||||
|
|
||||||
|
-- Sync Queue Management
|
||||||
|
-- name: CreateSyncQueueItem :one
|
||||||
|
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, max_attempts, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetSyncQueueItem :one
|
||||||
|
SELECT * FROM sync_queue WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ListPendingSyncQueueItems :many
|
||||||
|
SELECT * FROM sync_queue
|
||||||
|
WHERE device_id = $1 AND status = 'pending'
|
||||||
|
ORDER BY priority ASC, created_at ASC
|
||||||
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: UpdateSyncQueueItemStatus :one
|
||||||
|
UPDATE sync_queue
|
||||||
|
SET
|
||||||
|
status = $2,
|
||||||
|
attempts = attempts + 1,
|
||||||
|
error_message = $3,
|
||||||
|
processed_at = CASE WHEN $2 = 'completed' THEN NOW() ELSE NULL END
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteSyncQueueItem :exec
|
||||||
|
DELETE FROM sync_queue WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ClearDeviceSyncQueue :exec
|
||||||
|
DELETE FROM sync_queue WHERE device_id = $1;
|
||||||
|
|
||||||
|
-- Conflict Resolution
|
||||||
|
-- name: CreateSyncConflict :one
|
||||||
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetSyncConflict :one
|
||||||
|
SELECT * FROM sync_conflicts WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ListSyncConflictsByUser :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 AND sc.resolution_status = 'unresolved'
|
||||||
|
ORDER BY sc.created_at DESC;
|
||||||
|
|
||||||
|
-- name: ListSyncConflictsByMediaItem :many
|
||||||
|
SELECT * FROM sync_conflicts
|
||||||
|
WHERE media_item_id = $1 AND user_id = $2
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: ResolveSyncConflict :one
|
||||||
|
UPDATE sync_conflicts
|
||||||
|
SET
|
||||||
|
resolution_status = $2,
|
||||||
|
resolution_data = $3,
|
||||||
|
resolved_by = $4,
|
||||||
|
resolved_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteSyncConflict :exec
|
||||||
|
DELETE FROM sync_conflicts WHERE id = $1;
|
||||||
|
|
||||||
-- Media Items Admin Operations
|
-- Media Items Admin Operations
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookmann/internal/config"
|
||||||
|
"bookmann/internal/database"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/skip2/go-qrcode"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceHandler struct {
|
||||||
|
db *database.Queries
|
||||||
|
jwtKey []byte
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeviceHandler(db *database.Queries, jwtSecret string, cfg *config.Config) *DeviceHandler {
|
||||||
|
return &DeviceHandler{
|
||||||
|
db: db,
|
||||||
|
jwtKey: []byte(jwtSecret),
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceRegistrationRequest struct {
|
||||||
|
DeviceName string `json:"device_name" validate:"required,min=1,max=100"`
|
||||||
|
DeviceType string `json:"device_type" validate:"required,oneof=koreader kobo web mobile"`
|
||||||
|
DeviceIdentifier string `json:"device_identifier" validate:"required,min=1,max=255"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceRegistrationResponse struct {
|
||||||
|
DeviceID uuid.UUID `json:"device_id"`
|
||||||
|
RegistrationID string `json:"registration_id"`
|
||||||
|
AuthURL string `json:"auth_url"`
|
||||||
|
QRCode string `json:"qr_code"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
PollInterval int `json:"poll_interval"`
|
||||||
|
SetupInstructions map[string]string `json:"setup_instructions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceAuthStatusRequest struct {
|
||||||
|
RegistrationID string `json:"registration_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceAuthStatusResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
AuthToken string `json:"auth_token,omitempty"`
|
||||||
|
DeviceID uuid.UUID `json:"device_id,omitempty"`
|
||||||
|
SyncEndpoints map[string]string `json:"sync_endpoints,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
ExpiresIn int `json:"expires_in,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceListResponse struct {
|
||||||
|
Devices []DeviceInfo `json:"devices"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceInfo struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceType string `json:"device_type"`
|
||||||
|
LastSync *time.Time `json:"last_sync"`
|
||||||
|
LastSeen *time.Time `json:"last_seen"`
|
||||||
|
SyncEnabled bool `json:"sync_enabled"`
|
||||||
|
AutoSync bool `json:"auto_sync"`
|
||||||
|
SyncFrequency int32 `json:"sync_frequency_minutes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DeviceMetadata json.RawMessage `json:"device_metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceUpdateRequest struct {
|
||||||
|
DeviceName string `json:"device_name,omitempty" validate:"omitempty,min=1,max=100"`
|
||||||
|
SyncEnabled *bool `json:"sync_enabled,omitempty"`
|
||||||
|
AutoSync *bool `json:"auto_sync,omitempty"`
|
||||||
|
SyncFrequencyMinutes *int32 `json:"sync_frequency_minutes,omitempty" validate:"omitempty,min=1,max=1440"`
|
||||||
|
DeviceMetadata json.RawMessage `json:"device_metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceApprovalRequest struct {
|
||||||
|
RegistrationID string `json:"registration_id" validate:"required"`
|
||||||
|
Approve bool `json:"approve"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingRegistration struct {
|
||||||
|
RegistrationID string
|
||||||
|
DeviceName string
|
||||||
|
DeviceType string
|
||||||
|
DeviceIdentifier string
|
||||||
|
UserID uuid.UUID
|
||||||
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingRegistrations = make(map[string]*PendingRegistration)
|
||||||
|
|
||||||
|
func (h *DeviceHandler) InitiateRegistration(c echo.Context) error {
|
||||||
|
req := DeviceRegistrationRequest{}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
registrationID := uuid.New().String()
|
||||||
|
expiresAt := time.Now().Add(5 * time.Minute)
|
||||||
|
|
||||||
|
registration := &PendingRegistration{
|
||||||
|
RegistrationID: registrationID,
|
||||||
|
DeviceName: req.DeviceName,
|
||||||
|
DeviceType: req.DeviceType,
|
||||||
|
DeviceIdentifier: req.DeviceIdentifier,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRegistrations[registrationID] = registration
|
||||||
|
|
||||||
|
authURL := fmt.Sprintf("%s/devices/approve/%s", h.cfg.BaseURL, registrationID)
|
||||||
|
|
||||||
|
qrCode, err := qrcode.Encode(authURL, qrcode.Medium, 256)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate QR code"})
|
||||||
|
}
|
||||||
|
|
||||||
|
qrCodeBase64 := base64.StdEncoding.EncodeToString(qrCode)
|
||||||
|
|
||||||
|
setupInstructions := map[string]string{}
|
||||||
|
switch req.DeviceType {
|
||||||
|
case "koreader":
|
||||||
|
setupInstructions["koreader"] = fmt.Sprintf("Calibre URL: %s/api/sync/koreader", h.cfg.BaseURL)
|
||||||
|
case "kobo":
|
||||||
|
setupInstructions["kobo"] = fmt.Sprintf("Sync URL: %s/api/sync/kobo", h.cfg.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DeviceRegistrationResponse{
|
||||||
|
RegistrationID: registrationID,
|
||||||
|
AuthURL: authURL,
|
||||||
|
QRCode: "data:image/png;base64," + qrCodeBase64,
|
||||||
|
ExpiresIn: 300,
|
||||||
|
PollInterval: 3,
|
||||||
|
SetupInstructions: setupInstructions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusCreated, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeviceHandler) CheckRegistrationStatus(c echo.Context) error {
|
||||||
|
req := DeviceAuthStatusRequest{}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||||
|
}
|
||||||
|
|
||||||
|
registration, exists := pendingRegistrations[req.RegistrationID]
|
||||||
|
if !exists {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(registration.ExpiresAt) {
|
||||||
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if registration.UserID == (uuid.UUID{}) {
|
||||||
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
|
Status: "pending",
|
||||||
|
Message: "awaiting user approval",
|
||||||
|
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
authToken, err := generateDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
userUUID := registration.UserID
|
||||||
|
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||||
|
|
||||||
|
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||||
|
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||||
|
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||||
|
|
||||||
|
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeviceName: registration.DeviceName,
|
||||||
|
DeviceType: registration.DeviceType,
|
||||||
|
DeviceIdentifier: registration.DeviceIdentifier,
|
||||||
|
AuthToken: authToken,
|
||||||
|
SyncEnabled: syncEnabled,
|
||||||
|
AutoSync: autoSync,
|
||||||
|
SyncFrequencyMinutes: syncFreq,
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
|
|
||||||
|
syncEndpoints := map[string]string{}
|
||||||
|
switch registration.DeviceType {
|
||||||
|
case "koreader":
|
||||||
|
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
||||||
|
case "kobo":
|
||||||
|
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
|
Status: "approved",
|
||||||
|
AuthToken: authToken,
|
||||||
|
DeviceID: device.ID.Bytes,
|
||||||
|
SyncEndpoints: syncEndpoints,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeviceHandler) ListDevices(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||||
|
|
||||||
|
devices, err := h.db.ListDevicesByUser(c.Request().Context(), pgUserID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list devices"})
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceList := make([]DeviceInfo, 0, len(devices))
|
||||||
|
for _, device := range devices {
|
||||||
|
syncEnabled := device.SyncEnabled.Bool && device.SyncEnabled.Valid
|
||||||
|
autoSync := device.AutoSync.Bool && device.AutoSync.Valid
|
||||||
|
syncFreq := int32(0)
|
||||||
|
if device.SyncFrequencyMinutes.Valid {
|
||||||
|
syncFreq = device.SyncFrequencyMinutes.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceList = append(deviceList, DeviceInfo{
|
||||||
|
ID: device.ID.Bytes,
|
||||||
|
DeviceName: device.DeviceName,
|
||||||
|
DeviceType: device.DeviceType,
|
||||||
|
LastSync: (*time.Time)(&device.LastSync.Time),
|
||||||
|
LastSeen: (*time.Time)(&device.LastSeen.Time),
|
||||||
|
SyncEnabled: syncEnabled,
|
||||||
|
AutoSync: autoSync,
|
||||||
|
SyncFrequency: syncFreq,
|
||||||
|
CreatedAt: device.CreatedAt.Time,
|
||||||
|
DeviceMetadata: device.DeviceMetadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, DeviceListResponse{
|
||||||
|
Devices: deviceList,
|
||||||
|
Total: len(deviceList),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeviceHandler) GetDevice(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
|
||||||
|
|
||||||
|
device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if device.UserID.Bytes != userUUID {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
|
||||||
|
}
|
||||||
|
|
||||||
|
syncEnabled := device.SyncEnabled.Bool && device.SyncEnabled.Valid
|
||||||
|
autoSync := device.AutoSync.Bool && device.AutoSync.Valid
|
||||||
|
syncFreq := int32(0)
|
||||||
|
if device.SyncFrequencyMinutes.Valid {
|
||||||
|
syncFreq = device.SyncFrequencyMinutes.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, DeviceInfo{
|
||||||
|
ID: device.ID.Bytes,
|
||||||
|
DeviceName: device.DeviceName,
|
||||||
|
DeviceType: device.DeviceType,
|
||||||
|
LastSync: (*time.Time)(&device.LastSync.Time),
|
||||||
|
LastSeen: (*time.Time)(&device.LastSeen.Time),
|
||||||
|
SyncEnabled: syncEnabled,
|
||||||
|
AutoSync: autoSync,
|
||||||
|
SyncFrequency: syncFreq,
|
||||||
|
CreatedAt: device.CreatedAt.Time,
|
||||||
|
DeviceMetadata: device.DeviceMetadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeviceHandler) UpdateDevice(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
|
||||||
|
|
||||||
|
device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if device.UserID.Bytes != userUUID {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DeviceUpdateRequest{}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams := database.UpdateDeviceParams{
|
||||||
|
ID: pgDeviceID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DeviceName != "" {
|
||||||
|
updateParams.DeviceName = req.DeviceName
|
||||||
|
} else {
|
||||||
|
updateParams.DeviceName = device.DeviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SyncEnabled != nil {
|
||||||
|
updateParams.SyncEnabled = pgtype.Bool{Bool: *req.SyncEnabled, Valid: true}
|
||||||
|
} else {
|
||||||
|
updateParams.SyncEnabled = device.SyncEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AutoSync != nil {
|
||||||
|
updateParams.AutoSync = pgtype.Bool{Bool: *req.AutoSync, Valid: true}
|
||||||
|
} else {
|
||||||
|
updateParams.AutoSync = device.AutoSync
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SyncFrequencyMinutes != nil {
|
||||||
|
updateParams.SyncFrequencyMinutes = pgtype.Int4{Int32: *req.SyncFrequencyMinutes, Valid: true}
|
||||||
|
} else {
|
||||||
|
updateParams.SyncFrequencyMinutes = device.SyncFrequencyMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DeviceMetadata != nil {
|
||||||
|
updateParams.DeviceMetadata = req.DeviceMetadata
|
||||||
|
} else {
|
||||||
|
updateParams.DeviceMetadata = device.DeviceMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedDevice, err := h.db.UpdateDevice(c.Request().Context(), updateParams)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update device"})
|
||||||
|
}
|
||||||
|
|
||||||
|
syncEnabled := updatedDevice.SyncEnabled.Bool && updatedDevice.SyncEnabled.Valid
|
||||||
|
autoSync := updatedDevice.AutoSync.Bool && updatedDevice.AutoSync.Valid
|
||||||
|
syncFreq := int32(0)
|
||||||
|
if updatedDevice.SyncFrequencyMinutes.Valid {
|
||||||
|
syncFreq = updatedDevice.SyncFrequencyMinutes.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"device_updated": true,
|
||||||
|
"device": DeviceInfo{
|
||||||
|
ID: updatedDevice.ID.Bytes,
|
||||||
|
DeviceName: updatedDevice.DeviceName,
|
||||||
|
DeviceType: updatedDevice.DeviceType,
|
||||||
|
LastSync: (*time.Time)(&updatedDevice.LastSync.Time),
|
||||||
|
LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time),
|
||||||
|
SyncEnabled: syncEnabled,
|
||||||
|
AutoSync: autoSync,
|
||||||
|
SyncFrequency: syncFreq,
|
||||||
|
CreatedAt: updatedDevice.CreatedAt.Time,
|
||||||
|
DeviceMetadata: updatedDevice.DeviceMetadata,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeviceHandler) DeleteDevice(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
|
||||||
|
|
||||||
|
device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if device.UserID.Bytes != userUUID {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeviceHandler) ApproveDevice(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
registrationID := c.Param("registration_id")
|
||||||
|
|
||||||
|
registration, exists := pendingRegistrations[registrationID]
|
||||||
|
if !exists {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(registration.ExpiresAt) {
|
||||||
|
delete(pendingRegistrations, registrationID)
|
||||||
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
|
}
|
||||||
|
|
||||||
|
registration.UserID = userUUID
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "device approved successfully",
|
||||||
|
"device_name": registration.DeviceName,
|
||||||
|
"device_type": registration.DeviceType,
|
||||||
|
"registration_id": registrationID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeviceHandler) RejectDevice(c echo.Context) error {
|
||||||
|
registrationID := c.Param("registration_id")
|
||||||
|
|
||||||
|
_, exists := pendingRegistrations[registrationID]
|
||||||
|
if !exists {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(pendingRegistrations, registrationID)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{
|
||||||
|
"message": "device registration rejected",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeviceHandler) ListPendingRegistrations(c echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
registrations := []map[string]interface{}{}
|
||||||
|
for _, reg := range pendingRegistrations {
|
||||||
|
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||||
|
registrations = append(registrations, map[string]interface{}{
|
||||||
|
"registration_id": reg.RegistrationID,
|
||||||
|
"device_name": reg.DeviceName,
|
||||||
|
"device_type": reg.DeviceType,
|
||||||
|
"device_identifier": reg.DeviceIdentifier,
|
||||||
|
"expires_at": reg.ExpiresAt,
|
||||||
|
"created_at": reg.CreatedAt,
|
||||||
|
"is_approved": reg.UserID != (uuid.UUID{}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"registrations": registrations,
|
||||||
|
"total": len(registrations),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateDeviceToken() (string, error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedToken, err := bcrypt.GenerateFromPassword(bytes, bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token := base64.StdEncoding.EncodeToString(hashedToken)
|
||||||
|
|
||||||
|
return fmt.Sprintf("dev_%s", token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDeviceToken(hashedToken string, plainToken string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hashedToken), []byte(plainToken))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookmann/internal/database"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceContext struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
DeviceName string
|
||||||
|
DeviceType string
|
||||||
|
DeviceIdentifier string
|
||||||
|
SyncEnabled bool
|
||||||
|
AutoSync bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceAuthMiddleware struct {
|
||||||
|
db *database.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
||||||
|
return &DeviceAuthMiddleware{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||||
|
"error": "missing authorization header",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||||
|
"error": "invalid authorization header format",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
device, err := m.db.GetDeviceByAuthToken(c.Request().Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||||
|
"error": "invalid device token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !device.SyncEnabled.Bool || !device.SyncEnabled.Valid {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{
|
||||||
|
"error": "device sync is disabled",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := DeviceContext{
|
||||||
|
ID: device.ID.Bytes,
|
||||||
|
UserID: device.UserID.Bytes,
|
||||||
|
DeviceName: device.DeviceName,
|
||||||
|
DeviceType: device.DeviceType,
|
||||||
|
DeviceIdentifier: device.DeviceIdentifier,
|
||||||
|
SyncEnabled: device.SyncEnabled.Bool && device.SyncEnabled.Valid,
|
||||||
|
AutoSync: device.AutoSync.Bool && device.AutoSync.Valid,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("device", ctx)
|
||||||
|
c.Set("device_id", device.ID.Bytes)
|
||||||
|
c.Set("user_id", device.UserID.Bytes)
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeviceAuthMiddleware) RequirePermission(permission string) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
device, ok := c.Get("device").(DeviceContext)
|
||||||
|
if !ok {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||||
|
"error": "device not authenticated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.hasPermission(device.DeviceType, permission) {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{
|
||||||
|
"error": "insufficient permissions",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeviceAuthMiddleware) hasPermission(deviceType string, permission string) bool {
|
||||||
|
permissions := map[string][]string{
|
||||||
|
"koreader": {"sync:progress", "sync:annotations", "sync:metadata"},
|
||||||
|
"kobo": {"sync:progress", "sync:annotations", "sync:metadata"},
|
||||||
|
"web": {"sync:progress", "sync:annotations", "sync:metadata", "device:manage"},
|
||||||
|
"mobile": {"sync:progress", "sync:annotations", "sync:metadata"},
|
||||||
|
}
|
||||||
|
|
||||||
|
devicePerms, exists := permissions[deviceType]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range devicePerms {
|
||||||
|
if p == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DeviceAuthMiddleware) UpdateLastSeen(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
err := next(c)
|
||||||
|
|
||||||
|
deviceID, ok := c.Get("device_id").(uuid.UUID)
|
||||||
|
if ok {
|
||||||
|
pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
|
||||||
|
m.db.UpdateDeviceLastSeen(c.Request().Context(), pgDeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user