feat(media-supply): persist favorites with multi-group membership

Move media favorites from client state to PostgreSQL with server-side
search and pagination.

- Store favorites keyed by (tenant, user, group, resource) so a resource
  can belong to multiple groups; the 500-resource cap counts distinct
  resources, not memberships.
- Add favorite-group CRUD and group/global removal routes, with silent
  cleanup of delisted or invisible resources on read.
- Serve favorite resource details server-side, 10 per page, with name/ID
  search; favorites-page removal is group-scoped, resources-page removal
  is global.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:10:50 +08:00
parent 0bf4b73ebc
commit 67a6cbcd9e
15 changed files with 2378 additions and 518 deletions
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS media_supply_favorite_resources;
DROP TABLE IF EXISTS media_supply_favorite_groups;
DROP TABLE IF EXISTS media_supply_favorite_owners;
@@ -0,0 +1,57 @@
CREATE TABLE IF NOT EXISTS media_supply_favorite_owners (
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
revision BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, user_id),
CONSTRAINT chk_media_supply_favorite_owner_revision CHECK (revision >= 0)
);
CREATE TABLE IF NOT EXISTS media_supply_favorite_groups (
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
group_key VARCHAR(64) NOT NULL,
name VARCHAR(64) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, user_id, group_key),
CONSTRAINT fk_media_supply_favorite_group_owner
FOREIGN KEY (tenant_id, user_id)
REFERENCES media_supply_favorite_owners (tenant_id, user_id)
ON DELETE CASCADE,
CONSTRAINT chk_media_supply_favorite_group_key_not_blank CHECK (BTRIM(group_key) <> ''),
CONSTRAINT chk_media_supply_favorite_group_name_not_blank CHECK (BTRIM(name) <> '')
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_media_supply_favorite_group_name
ON media_supply_favorite_groups (tenant_id, user_id, LOWER(name));
CREATE INDEX IF NOT EXISTS idx_media_supply_favorite_group_owner_order
ON media_supply_favorite_groups (tenant_id, user_id, sort_order, created_at);
CREATE TABLE IF NOT EXISTS media_supply_favorite_resources (
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
group_key VARCHAR(64) NOT NULL,
resource_id BIGINT NOT NULL REFERENCES supplier_media_resources(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, user_id, group_key, resource_id),
CONSTRAINT fk_media_supply_favorite_resource_group
FOREIGN KEY (tenant_id, user_id, group_key)
REFERENCES media_supply_favorite_groups (tenant_id, user_id, group_key)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_media_supply_favorite_group_page
ON media_supply_favorite_resources (
tenant_id,
user_id,
group_key,
created_at DESC,
resource_id DESC
);
CREATE INDEX IF NOT EXISTS idx_media_supply_favorite_resource_lookup
ON media_supply_favorite_resources (resource_id, tenant_id, user_id);