feat(db): add saved_filters table and CRUD operations
Add database schema and SQL queries for generic saved filters system that allows users to save custom filter presets for any resource type. Database Schema: - Add saved_filters table with user_id, name, resource_type, filters (JSONB) - Create composite index on (user_id, resource_type) for efficient lookups - Create index on (user_id, name) for future name search feature - Add update_updated_at_column() trigger to auto-update timestamps - Make trigger creation idempotent with DROP TRIGGER IF EXISTS SQL Queries (5 new queries): - GetSavedFilters: List all filters for user + resource type - GetSavedFilterByID: Retrieve single filter by ID - CreateSavedFilter: Create new saved filter - UpdateSavedFilter: Update filter name/criteria - DeleteSavedFilter: Remove saved filter Design Decisions: - Generic resource_type field supports any resource (media-items, collections, devices) - JSONB filters field allows flexible schema without migrations - User-scoped via JWT (user_id foreign key with CASCADE delete) - Automatic updated_at timestamp via database trigger Generated Code: - database.SavedFilters model (10 fields including JSONB filters) - All 5 CRUD query functions with proper parameter types - pgtype.UUID wrappers for UUID parameters Part of: Saved Filters Implementation (Phase 1: Database) Related: #saved-filters-feature
This commit is contained in:
@@ -992,3 +992,36 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
|
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
|
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved);
|
CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved);
|
||||||
|
|
||||||
|
-- Create saved_filters for users to be able to filter different parts of the application
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL, -- 'media-items', 'collections', 'devices', etc.
|
||||||
|
filters JSONB NOT NULL, -- {search: "", author_filter: "", genre: "", ...}
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for efficient user+resource lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_saved_filters_user_resource ON saved_filters(user_id, resource_type);
|
||||||
|
|
||||||
|
-- Index for name searches (future feature)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_saved_filters_name ON saved_filters(user_id, name);
|
||||||
|
|
||||||
|
-- Trigger to auto-update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_saved_filters_updated_at ON saved_filters;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_saved_filters_updated_at
|
||||||
|
BEFORE UPDATE ON saved_filters
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|||||||
@@ -319,6 +319,16 @@ type RefreshTokens struct {
|
|||||||
RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"`
|
RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SavedFilters struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
ResourceType string `db:"resource_type" json:"resource_type"`
|
||||||
|
Filters []byte `db:"filters" json:"filters"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type SyncConflicts struct {
|
type SyncConflicts struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ 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)
|
||||||
|
CreateSavedFilter(ctx context.Context, arg CreateSavedFilterParams) (SavedFilters, error)
|
||||||
// Conflict Resolution
|
// Conflict Resolution
|
||||||
CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error)
|
CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error)
|
||||||
CreateSyncHistoryEntry(ctx context.Context, arg CreateSyncHistoryEntryParams) (SyncQueue, error)
|
CreateSyncHistoryEntry(ctx context.Context, arg CreateSyncHistoryEntryParams) (SyncQueue, error)
|
||||||
@@ -104,6 +105,7 @@ 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
|
||||||
|
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) error
|
||||||
DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error
|
DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error
|
DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error
|
||||||
// Delete system config
|
// Delete system config
|
||||||
@@ -212,6 +214,8 @@ type Querier interface {
|
|||||||
GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAddedItemsParams) ([]MediaItems, error)
|
GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAddedItemsParams) ([]MediaItems, error)
|
||||||
GetRecentlyReadItems(ctx context.Context, arg GetRecentlyReadItemsParams) ([]MediaItems, error)
|
GetRecentlyReadItems(ctx context.Context, arg GetRecentlyReadItemsParams) ([]MediaItems, error)
|
||||||
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
|
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
|
||||||
|
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
|
||||||
|
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
|
||||||
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, 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)
|
||||||
@@ -328,6 +332,7 @@ type Querier interface {
|
|||||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||||
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)
|
||||||
|
UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error)
|
||||||
UpdateSyncQueueItemStatus(ctx context.Context, arg UpdateSyncQueueItemStatusParams) (SyncQueue, error)
|
UpdateSyncQueueItemStatus(ctx context.Context, arg UpdateSyncQueueItemStatusParams) (SyncQueue, error)
|
||||||
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
||||||
// Update universal progress
|
// Update universal progress
|
||||||
|
|||||||
@@ -960,6 +960,39 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CreateSavedFilter = `-- name: CreateSavedFilter :one
|
||||||
|
INSERT INTO saved_filters (user_id, name, resource_type, filters)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, user_id, name, resource_type, filters, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateSavedFilterParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
ResourceType string `db:"resource_type" json:"resource_type"`
|
||||||
|
Filters []byte `db:"filters" json:"filters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateSavedFilter(ctx context.Context, arg CreateSavedFilterParams) (SavedFilters, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CreateSavedFilter,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Name,
|
||||||
|
arg.ResourceType,
|
||||||
|
arg.Filters,
|
||||||
|
)
|
||||||
|
var i SavedFilters
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.ResourceType,
|
||||||
|
&i.Filters,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const CreateSyncConflict = `-- name: CreateSyncConflict :one
|
const CreateSyncConflict = `-- 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)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
@@ -1389,6 +1422,21 @@ func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingPr
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DeleteSavedFilter = `-- name: DeleteSavedFilter :exec
|
||||||
|
DELETE FROM saved_filters
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteSavedFilterParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, DeleteSavedFilter, arg.ID, arg.UserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const DeleteSyncConflict = `-- name: DeleteSyncConflict :exec
|
const DeleteSyncConflict = `-- name: DeleteSyncConflict :exec
|
||||||
DELETE FROM sync_conflicts WHERE id = $1
|
DELETE FROM sync_conflicts WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -4393,6 +4441,70 @@ func (q *Queries) GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRe
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetSavedFilterByID = `-- name: GetSavedFilterByID :one
|
||||||
|
SELECT id, user_id, name, resource_type, filters, created_at, updated_at FROM saved_filters
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSavedFilterByIDParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetSavedFilterByID, arg.ID, arg.UserID)
|
||||||
|
var i SavedFilters
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.ResourceType,
|
||||||
|
&i.Filters,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetSavedFilters = `-- name: GetSavedFilters :many
|
||||||
|
SELECT id, user_id, name, resource_type, filters, created_at, updated_at FROM saved_filters
|
||||||
|
WHERE user_id = $1 AND resource_type = $2
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSavedFiltersParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
ResourceType string `db:"resource_type" json:"resource_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetSavedFilters, arg.UserID, arg.ResourceType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SavedFilters{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i SavedFilters
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.ResourceType,
|
||||||
|
&i.Filters,
|
||||||
|
&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 GetStuckSyncQueueItems = `-- name: GetStuckSyncQueueItems :many
|
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
|
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'
|
WHERE status = 'processing' AND created_at < NOW() - INTERVAL '1 hour'
|
||||||
@@ -8373,6 +8485,42 @@ func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingPr
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpdateSavedFilter = `-- name: UpdateSavedFilter :one
|
||||||
|
UPDATE saved_filters
|
||||||
|
SET name = $1,
|
||||||
|
filters = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3 AND user_id = $4
|
||||||
|
RETURNING id, user_id, name, resource_type, filters, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateSavedFilterParams struct {
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Filters []byte `db:"filters" json:"filters"`
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateSavedFilter,
|
||||||
|
arg.Name,
|
||||||
|
arg.Filters,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
|
var i SavedFilters
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.ResourceType,
|
||||||
|
&i.Filters,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateSyncQueueItemStatus = `-- name: UpdateSyncQueueItemStatus :one
|
const UpdateSyncQueueItemStatus = `-- name: UpdateSyncQueueItemStatus :one
|
||||||
UPDATE sync_queue
|
UPDATE sync_queue
|
||||||
SET
|
SET
|
||||||
|
|||||||
@@ -1721,3 +1721,29 @@ LIMIT $3;
|
|||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE mi.library_id = $1
|
WHERE mi.library_id = $1
|
||||||
ORDER BY mi.created_at DESC;
|
ORDER BY mi.created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetSavedFilters :many
|
||||||
|
SELECT * FROM saved_filters
|
||||||
|
WHERE user_id = @user_id AND resource_type = @resource_type
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetSavedFilterByID :one
|
||||||
|
SELECT * FROM saved_filters
|
||||||
|
WHERE id = @id AND user_id = @user_id;
|
||||||
|
|
||||||
|
-- name: CreateSavedFilter :one
|
||||||
|
INSERT INTO saved_filters (user_id, name, resource_type, filters)
|
||||||
|
VALUES (@user_id, @name, @resource_type, @filters)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateSavedFilter :one
|
||||||
|
UPDATE saved_filters
|
||||||
|
SET name = @name,
|
||||||
|
filters = @filters,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = @id AND user_id = @user_id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteSavedFilter :exec
|
||||||
|
DELETE FROM saved_filters
|
||||||
|
WHERE id = @id AND user_id = @user_id;
|
||||||
|
|||||||
Reference in New Issue
Block a user