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:
2026-03-21 00:16:05 -04:00
parent a63394f429
commit e17a96123f
5 changed files with 222 additions and 0 deletions
+33
View File
@@ -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_content_id ON unlinked_books(content_id);
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();
+10
View File
@@ -319,6 +319,16 @@ type RefreshTokens struct {
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 {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
+5
View File
@@ -72,6 +72,7 @@ type Querier interface {
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
// Refresh Tokens queries
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error)
CreateSavedFilter(ctx context.Context, arg CreateSavedFilterParams) (SavedFilters, error)
// Conflict Resolution
CreateSyncConflict(ctx context.Context, arg CreateSyncConflictParams) (SyncConflicts, error)
CreateSyncHistoryEntry(ctx context.Context, arg CreateSyncHistoryEntryParams) (SyncQueue, error)
@@ -104,6 +105,7 @@ type Querier interface {
DeleteMediaNote(ctx context.Context, id pgtype.UUID) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) error
DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error
DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error
// Delete system config
@@ -212,6 +214,8 @@ type Querier interface {
GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAddedItemsParams) ([]MediaItems, error)
GetRecentlyReadItems(ctx context.Context, arg GetRecentlyReadItemsParams) ([]MediaItems, 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)
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, 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)
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) 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)
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
// Update universal progress
+148
View File
@@ -960,6 +960,39 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken
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
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
VALUES ($1, $2, $3, $4)
@@ -1389,6 +1422,21 @@ func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingPr
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
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
}
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
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'
@@ -8373,6 +8485,42 @@ func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingPr
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
UPDATE sync_queue
SET
+26
View File
@@ -1721,3 +1721,29 @@ LIMIT $3;
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
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;