From f1e82f1eec1a64bb71c23a9e7620eef9f78c2c74 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 00:04:25 -0400 Subject: [PATCH] Rename webhook sync actors to profile mappings - Rename actor-oriented API, storage, and webhook fields to user/profile terminology - Switch dev compose helpers to use docker-compose.yml - Update frontend types and webhook sync settings for the new endpoints --- Makefile.local.example | 15 +- README.md | 2 +- internal/api/handlers/webhook_sync.go | 60 ++-- internal/api/handlers/webhook_sync_test.go | 24 +- internal/api/router.go | 4 +- internal/webhooksync/providers.go | 42 +-- internal/webhooksync/providers_test.go | 4 +- internal/webhooksync/repo.go | 94 ++--- internal/webhooksync/service.go | 56 +-- internal/webhooksync/service_test.go | 4 +- internal/webhooksync/types.go | 56 +-- ..._webhook_sync_silo_profile_column.down.sql | 30 ++ ...35_webhook_sync_silo_profile_column.up.sql | 30 ++ ...hook_sync_rename_actor_to_profile.down.sql | 26 ++ ...ebhook_sync_rename_actor_to_profile.up.sql | 26 ++ web/src/api/types.ts | 26 +- web/src/hooks/queries/keys.ts | 3 +- web/src/hooks/queries/webhook-sync.ts | 38 +- .../pages/settings/WebhookSyncSettings.tsx | 329 ++++++++++++------ 19 files changed, 553 insertions(+), 316 deletions(-) create mode 100644 migrations/135_webhook_sync_silo_profile_column.down.sql create mode 100644 migrations/135_webhook_sync_silo_profile_column.up.sql create mode 100644 migrations/136_webhook_sync_rename_actor_to_profile.down.sql create mode 100644 migrations/136_webhook_sync_rename_actor_to_profile.up.sql diff --git a/Makefile.local.example b/Makefile.local.example index c29b9d74..83047b6c 100644 --- a/Makefile.local.example +++ b/Makefile.local.example @@ -12,6 +12,7 @@ DEV_IMPORT_CATALOG_SEED_PATH ?= /tmp/silo-catalog-seed DEV_PLUGIN_SDK_DIR ?= ../silo-plugin-sdk DEV_LOCAL_IMAGE ?= silo:dev-local DEV_JELLYCOMPAT_DEBUG_LOG ?= /opt/silo/jellycompat-debug.log +DEV_COMPOSE_FILE ?= docker-compose.yml RSYNC ?= $(shell command -v rsync || echo /usr/bin/rsync) SILO_IMAGE ?= ghcr.io/silo-server/silo-server:latest @@ -44,7 +45,7 @@ deploy-dev: --load \ .' @echo "==> Starting services via Docker Compose..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && SILO_IMAGE=$(DEV_LOCAL_IMAGE) MEDIA_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f docker-compose.dev.yml --profile integrated up -d --force-recreate silo' + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && SILO_IMAGE=$(DEV_LOCAL_IMAGE) MEDIA_ROOT="$(DEV_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f $(DEV_COMPOSE_FILE) up -d --force-recreate silo' @echo "==> Pruning old images..." ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' @echo "==> Verifying deployment..." @@ -56,7 +57,7 @@ dev-deploy: deploy-dev deploy-dev-image: @echo "==> Syncing compose config to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/..." - $(RSYNC) -az docker-compose.dev.yml $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ + $(RSYNC) -az $(DEV_COMPOSE_FILE) $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ $(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/ @if [ -f .env ]; then \ $(RSYNC) -az .env $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/; \ @@ -68,7 +69,7 @@ deploy-dev-image: @echo "==> Pulling $(SILO_IMAGE) on remote..." ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' @echo "==> Starting services via Docker Compose..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f docker-compose.dev.yml --profile integrated up -d --force-recreate silo' + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f $(DEV_COMPOSE_FILE) up -d --force-recreate silo' @echo "==> Verifying deployment..." @sleep 5 ssh $(DEV_DEPLOY_HOST) 'docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"' @@ -78,7 +79,7 @@ dev-remote-stop: @echo "==> Stopping air tmux session if running..." ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' @echo "==> Stopping dev stack..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f docker-compose.dev.yml --profile integrated down' + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f $(DEV_COMPOSE_FILE) down' @echo "==> Done." deploy-dev-transcode: @@ -94,16 +95,16 @@ deploy-dev-transcode: dev-deploy-transcode: deploy-dev-transcode deploy-dev-import: - $(RSYNC) -az docker-compose.dev.yml $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ + $(RSYNC) -az $(DEV_COMPOSE_FILE) $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ $(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/ ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" CATALOG_SEED_PATH="$(DEV_IMPORT_CATALOG_SEED_PATH)" docker compose -p silo-import -f docker-compose.dev.yml --profile integrated up -d --force-recreate' + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" CATALOG_SEED_PATH="$(DEV_IMPORT_CATALOG_SEED_PATH)" docker compose -p silo-import -f $(DEV_COMPOSE_FILE) up -d --force-recreate' ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' dev-deploy-import: deploy-dev-import deploy-dev-import-reset: - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -p silo-import -f docker-compose.dev.yml down -v --remove-orphans || true' + ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -p silo-import -f $(DEV_COMPOSE_FILE) down -v --remove-orphans || true' $(MAKE) deploy-dev-import dev-deploy-import-reset: deploy-dev-import-reset diff --git a/README.md b/README.md index 6b1bf41a..de86f351 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ helper and cutover guide in [docs/continuum-to-silo-docker-migration.md](docs/co ## Development -Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.dev.yml](docker-compose.dev.yml) and the existing source-build workflow for local development. +Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) and the existing source-build workflow for local development. ```sh # Run the frontend dev server (hot reload, proxies API to :8090) diff --git a/internal/api/handlers/webhook_sync.go b/internal/api/handlers/webhook_sync.go index bbcf6223..e14d916c 100644 --- a/internal/api/handlers/webhook_sync.go +++ b/internal/api/handlers/webhook_sync.go @@ -167,7 +167,7 @@ func (h *WebhookSyncHandler) HandleLegacyCreateConnection(w http.ResponseWriter, h.writeError(w, err) return } - actors, err := h.service.GetActors(r.Context(), userID, resp.Connection.ID) + actors, err := h.service.GetProfileMappings(r.Context(), userID, resp.Connection.ID) if err != nil { h.writeError(w, err) return @@ -251,14 +251,14 @@ func (h *WebhookSyncHandler) HandleLegacyRotateWebhook(w http.ResponseWriter, r }) } -func (h *WebhookSyncHandler) HandleGetActors(w http.ResponseWriter, r *http.Request) { +func (h *WebhookSyncHandler) HandleGetProfileMappings(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) id := chi.URLParam(r, "id") if id == "" { writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") return } - resp, err := h.service.GetActors(r.Context(), userID, id) + resp, err := h.service.GetProfileMappings(r.Context(), userID, id) if err != nil { h.writeError(w, err) return @@ -292,7 +292,7 @@ func (h *WebhookSyncHandler) HandleLegacyGetActors(w http.ResponseWriter, r *htt writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") return } - resp, err := h.service.GetActors(r.Context(), userID, id) + resp, err := h.service.GetProfileMappings(r.Context(), userID, id) if err != nil { h.writeError(w, err) return @@ -300,19 +300,19 @@ func (h *WebhookSyncHandler) HandleLegacyGetActors(w http.ResponseWriter, r *htt writeJSON(w, http.StatusOK, toLegacyPlexActorsResponse(resp)) } -func (h *WebhookSyncHandler) HandleUpdateActors(w http.ResponseWriter, r *http.Request) { +func (h *WebhookSyncHandler) HandleUpdateProfileMappings(w http.ResponseWriter, r *http.Request) { userID := apimw.GetUserID(r.Context()) id := chi.URLParam(r, "id") if id == "" { writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required") return } - var req webhooksync.UpdateActorMappingsInput + var req webhooksync.UpdateProfileMappingsInput if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - resp, err := h.service.UpdateActors(r.Context(), userID, id, req) + resp, err := h.service.UpdateProfileMappings(r.Context(), userID, id, req) if err != nil { h.writeError(w, err) return @@ -332,18 +332,18 @@ func (h *WebhookSyncHandler) HandleLegacyUpdateActors(w http.ResponseWriter, r * writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - input := webhooksync.UpdateActorMappingsInput{ - Mappings: make([]webhooksync.UpdateActorMapping, 0, len(req.Mappings)), + input := webhooksync.UpdateProfileMappingsInput{ + Mappings: make([]webhooksync.UpdateProfileMapping, 0, len(req.Mappings)), } for _, mapping := range req.Mappings { profileID := mapping.SiloProfileID - input.Mappings = append(input.Mappings, webhooksync.UpdateActorMapping{ - ExternalActorID: strconv.FormatInt(mapping.PlexAccountID, 10), - ExternalActorName: mapping.PlexAccountTitle, - SiloProfileID: &profileID, + input.Mappings = append(input.Mappings, webhooksync.UpdateProfileMapping{ + ExternalUserID: strconv.FormatInt(mapping.PlexAccountID, 10), + ExternalUserName: mapping.PlexAccountTitle, + SiloProfileID: &profileID, }) } - resp, err := h.service.UpdateActors(r.Context(), userID, id, input) + resp, err := h.service.UpdateProfileMappings(r.Context(), userID, id, input) if err != nil { h.writeError(w, err) return @@ -460,11 +460,11 @@ func webhookEventAttrs(meta webhooksync.WebhookRequestLogContext, result *webhoo if result.Action != "" { attrs["action"] = result.Action } - if result.ActorID != "" { - attrs["actor_id"] = result.ActorID + if result.UserID != "" { + attrs["external_user_id"] = result.UserID } - if result.ActorName != "" { - attrs["actor_name"] = result.ActorName + if result.UserName != "" { + attrs["external_user_name"] = result.UserName } if result.ExternalItemID != "" { attrs["external_item_id"] = result.ExternalItemID @@ -506,8 +506,8 @@ func logWebhookDelivery(result *webhooksync.ProcessWebhookResult, meta webhooksy if result.Action != "" { args = append(args, "action", result.Action) } - if result.ActorID != "" { - args = append(args, "actor_id", result.ActorID) + if result.UserID != "" { + args = append(args, "external_user_id", result.UserID) } if result.ExternalItemID != "" { args = append(args, "external_item_id", result.ExternalItemID) @@ -576,7 +576,7 @@ func toLegacyPlexConnection(connection webhooksync.Connection, webhookURL string PlexServerName: connection.ServerName, WebhookURL: webhookURL, BindingsReady: false, - ActorCount: connection.ActorCount, + ActorCount: connection.UserCount, AccountDiscoveryAvailable: connection.AccountDiscoveryAvailable, LastWebhookReceivedAt: connection.LastWebhookReceivedAt, LastWebhookErrorAt: connection.LastWebhookErrorAt, @@ -586,24 +586,24 @@ func toLegacyPlexConnection(connection webhooksync.Connection, webhookURL string } } -func toLegacyPlexActorsResponse(resp *webhooksync.ActorMappingsResponse) legacyPlexSyncActorsResponse { +func toLegacyPlexActorsResponse(resp *webhooksync.ProfileMappingsResponse) legacyPlexSyncActorsResponse { if resp == nil { return legacyPlexSyncActorsResponse{} } return legacyPlexSyncActorsResponse{ Mappings: toLegacyPlexActorMappings(resp.Mappings), - DiscoveredActors: toLegacyPlexDiscoveredActors(resp.DiscoveredActors), + DiscoveredActors: toLegacyPlexDiscoveredActors(resp.DiscoveredUsers), AccountDiscoveryAvailable: resp.AccountDiscoveryAvailable, } } -func toLegacyPlexActorMappings(mappings []webhooksync.ActorMapping) []legacyPlexSyncActorMapping { +func toLegacyPlexActorMappings(mappings []webhooksync.ProfileMapping) []legacyPlexSyncActorMapping { if len(mappings) == 0 { return nil } out := make([]legacyPlexSyncActorMapping, 0, len(mappings)) for _, mapping := range mappings { - accountID, err := strconv.ParseInt(mapping.ExternalActorID, 10, 64) + accountID, err := strconv.ParseInt(mapping.ExternalUserID, 10, 64) if err != nil { continue } @@ -611,7 +611,7 @@ func toLegacyPlexActorMappings(mappings []webhooksync.ActorMapping) []legacyPlex ID: mapping.ID, ConnectionID: mapping.ConnectionID, PlexAccountID: accountID, - PlexAccountTitle: mapping.ExternalActorName, + PlexAccountTitle: mapping.ExternalUserName, SiloProfileID: valueOrEmpty(mapping.SiloProfileID), CreatedAt: mapping.CreatedAt, UpdatedAt: mapping.UpdatedAt, @@ -620,25 +620,25 @@ func toLegacyPlexActorMappings(mappings []webhooksync.ActorMapping) []legacyPlex return out } -func toLegacyPlexDiscoveredActors(actors []webhooksync.DiscoveredActor) []legacyPlexSyncDiscoveredActor { +func toLegacyPlexDiscoveredActors(actors []webhooksync.DiscoveredUser) []legacyPlexSyncDiscoveredActor { if len(actors) == 0 { return nil } out := make([]legacyPlexSyncDiscoveredActor, 0, len(actors)) for _, actor := range actors { - accountID, err := strconv.ParseInt(actor.ExternalActorID, 10, 64) + accountID, err := strconv.ParseInt(actor.ExternalUserID, 10, 64) if err != nil { continue } out = append(out, legacyPlexSyncDiscoveredActor{ PlexAccountID: accountID, - PlexAccountTitle: actor.ExternalActorName, + PlexAccountTitle: actor.ExternalUserName, }) } return out } -func firstLegacyMappedActor(mappings []webhooksync.ActorMapping) legacyPlexSyncActorMapping { +func firstLegacyMappedActor(mappings []webhooksync.ProfileMapping) legacyPlexSyncActorMapping { legacyMappings := toLegacyPlexActorMappings(mappings) if len(legacyMappings) == 0 { return legacyPlexSyncActorMapping{} diff --git a/internal/api/handlers/webhook_sync_test.go b/internal/api/handlers/webhook_sync_test.go index 53fe0ff4..19912e6f 100644 --- a/internal/api/handlers/webhook_sync_test.go +++ b/internal/api/handlers/webhook_sync_test.go @@ -23,21 +23,21 @@ func TestToLegacyPlexActorsResponse(t *testing.T) { now := time.Date(2026, 4, 7, 12, 0, 0, 0, time.UTC) profileID := "profile-1" - resp := toLegacyPlexActorsResponse(&webhooksync.ActorMappingsResponse{ - Mappings: []webhooksync.ActorMapping{ + resp := toLegacyPlexActorsResponse(&webhooksync.ProfileMappingsResponse{ + Mappings: []webhooksync.ProfileMapping{ { - ID: 11, - ConnectionID: "conn-1", - ExternalActorID: "42", - ExternalActorName: "Alice", - SiloProfileID: &profileID, - CreatedAt: now, - UpdatedAt: now, + ID: 11, + ConnectionID: "conn-1", + ExternalUserID: "42", + ExternalUserName: "Alice", + SiloProfileID: &profileID, + CreatedAt: now, + UpdatedAt: now, }, }, - DiscoveredActors: []webhooksync.DiscoveredActor{ - {ExternalActorID: "42", ExternalActorName: "Alice"}, - {ExternalActorID: "77", ExternalActorName: "Bob"}, + DiscoveredUsers: []webhooksync.DiscoveredUser{ + {ExternalUserID: "42", ExternalUserName: "Alice"}, + {ExternalUserID: "77", ExternalUserName: "Bob"}, }, AccountDiscoveryAvailable: true, }) diff --git a/internal/api/router.go b/internal/api/router.go index cb80bce5..e7fbf1f5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1425,8 +1425,8 @@ func NewRouter(deps Dependencies) chi.Router { r.Delete("/connections/{id}", webhookSyncHandler.HandleDeleteConnection) r.Post("/connections/{id}/webhook/rotate", webhookSyncHandler.HandleRotateWebhook) r.Get("/connections/{id}/events", webhookSyncHandler.HandleListEvents) - r.Get("/connections/{id}/actors", webhookSyncHandler.HandleGetActors) - r.Put("/connections/{id}/actors", webhookSyncHandler.HandleUpdateActors) + r.Get("/connections/{id}/profile-mappings", webhookSyncHandler.HandleGetProfileMappings) + r.Put("/connections/{id}/profile-mappings", webhookSyncHandler.HandleUpdateProfileMappings) }) } diff --git a/internal/webhooksync/providers.go b/internal/webhooksync/providers.go index 038e20bf..e7f654a8 100644 --- a/internal/webhooksync/providers.go +++ b/internal/webhooksync/providers.go @@ -34,7 +34,7 @@ func (p *PlexProvider) ValidateCreateInput(input CreateConnectionInput) error { return nil } -func (p *PlexProvider) DefaultActor(ctx context.Context, _ *Connection, input CreateConnectionInput) (string, string, bool, error) { +func (p *PlexProvider) DefaultUser(ctx context.Context, _ *Connection, input CreateConnectionInput) (string, string, bool, error) { account, err := p.client.GetCurrentUser(ctx, input.AccessToken) if err != nil { return "", "", false, err @@ -42,20 +42,20 @@ func (p *PlexProvider) DefaultActor(ctx context.Context, _ *Connection, input Cr return strconv.Itoa(account.ID), account.Title, true, nil } -func (p *PlexProvider) DiscoverActors(ctx context.Context, conn *Connection, mappings []ActorMapping) ([]DiscoveredActor, bool, error) { +func (p *PlexProvider) DiscoverUsers(ctx context.Context, conn *Connection, mappings []ProfileMapping) ([]DiscoveredUser, bool, error) { if strings.TrimSpace(conn.BaseURL) == "" || strings.TrimSpace(conn.AccessToken) == "" { - return mappingsToDiscoveredActors(mappings), false, nil + return mappingsToDiscoveredUsers(mappings), false, nil } accounts, err := p.client.ListAccounts(ctx, conn.BaseURL, conn.AccessToken) if err != nil { return nil, false, err } accounts = filterDiscoveredAccounts(accounts, mappings) - result := make([]DiscoveredActor, 0, len(accounts)) + result := make([]DiscoveredUser, 0, len(accounts)) for _, a := range accounts { - result = append(result, DiscoveredActor{ - ExternalActorID: a.ID, - ExternalActorName: a.Name, + result = append(result, DiscoveredUser{ + ExternalUserID: a.ID, + ExternalUserName: a.Name, }) } return result, true, nil @@ -121,8 +121,8 @@ func (p *PlexProvider) ParseWebhook(ctx context.Context, conn *Connection, r *ht OccurredAt: occurredAt, Action: ActionImportProgress, EventKind: payload.Event, - ActorID: strconv.FormatInt(payload.Account.ID, 10), - ActorName: payload.Account.Title, + UserID: strconv.FormatInt(payload.Account.ID, 10), + UserName: payload.Account.Title, ExternalItemID: payload.Metadata.RatingKey, MediaKind: payload.Metadata.Type, Completed: canonical.Played, @@ -147,12 +147,12 @@ func (p *EmbyProvider) ValidateCreateInput(input CreateConnectionInput) error { return nil } -func (p *EmbyProvider) DefaultActor(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) { +func (p *EmbyProvider) DefaultUser(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) { return "", "", false, nil } -func (p *EmbyProvider) DiscoverActors(_ context.Context, _ *Connection, mappings []ActorMapping) ([]DiscoveredActor, bool, error) { - return mappingsToDiscoveredActors(mappings), false, nil +func (p *EmbyProvider) DiscoverUsers(_ context.Context, _ *Connection, mappings []ProfileMapping) ([]DiscoveredUser, bool, error) { + return mappingsToDiscoveredUsers(mappings), false, nil } func (p *EmbyProvider) ParseWebhook(_ context.Context, conn *Connection, r *http.Request) (*CanonicalEvent, error) { @@ -191,8 +191,8 @@ func (p *EmbyProvider) ParseWebhook(_ context.Context, conn *Connection, r *http OccurredAt: record.UpdatedAt, Action: action, EventKind: eventName, - ActorID: payload.User.ID, - ActorName: payload.User.Name, + UserID: payload.User.ID, + UserName: payload.User.Name, ExternalItemID: payload.Item.ID, MediaKind: mediaKind, Completed: record.Played, @@ -338,12 +338,12 @@ func (p *JellyfinProvider) ValidateCreateInput(input CreateConnectionInput) erro return nil } -func (p *JellyfinProvider) DefaultActor(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) { +func (p *JellyfinProvider) DefaultUser(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) { return "", "", false, nil } -func (p *JellyfinProvider) DiscoverActors(_ context.Context, _ *Connection, mappings []ActorMapping) ([]DiscoveredActor, bool, error) { - return mappingsToDiscoveredActors(mappings), false, nil +func (p *JellyfinProvider) DiscoverUsers(_ context.Context, _ *Connection, mappings []ProfileMapping) ([]DiscoveredUser, bool, error) { + return mappingsToDiscoveredUsers(mappings), false, nil } func (p *JellyfinProvider) ParseWebhook(_ context.Context, conn *Connection, r *http.Request) (*CanonicalEvent, error) { @@ -412,8 +412,8 @@ func (p *JellyfinProvider) ParseWebhook(_ context.Context, conn *Connection, r * OccurredAt: record.UpdatedAt, Action: ActionImportProgress, EventKind: payload.NotificationType, - ActorID: payload.User.ID, - ActorName: payload.User.Name, + UserID: payload.User.ID, + UserName: payload.User.Name, ExternalItemID: payload.Item.ID, MediaKind: mediaKind, Completed: record.Played, @@ -562,13 +562,13 @@ func shouldApplyPlexWebhookEvent(event string) bool { } } -func filterDiscoveredAccounts(accounts []historyimport.ExternalUser, mappings []ActorMapping) []historyimport.ExternalUser { +func filterDiscoveredAccounts(accounts []historyimport.ExternalUser, mappings []ProfileMapping) []historyimport.ExternalUser { if len(accounts) == 0 { return nil } mappedIDs := make(map[string]struct{}, len(mappings)) for _, mapping := range mappings { - mappedIDs[mapping.ExternalActorID] = struct{}{} + mappedIDs[mapping.ExternalUserID] = struct{}{} } filtered := make([]historyimport.ExternalUser, 0, len(accounts)) for _, account := range accounts { diff --git a/internal/webhooksync/providers_test.go b/internal/webhooksync/providers_test.go index dab964c3..89404097 100644 --- a/internal/webhooksync/providers_test.go +++ b/internal/webhooksync/providers_test.go @@ -45,8 +45,8 @@ func TestEmbyProviderParseWebhook(t *testing.T) { if event.Action != ActionImportProgress { t.Fatalf("unexpected action: %q", event.Action) } - if event.ActorID != "user-1" || event.ActorName != "Alice" { - t.Fatalf("unexpected actor: %#v", event) + if event.UserID != "user-1" || event.UserName != "Alice" { + t.Fatalf("unexpected external user: %#v", event) } if event.Record.Kind != "episode" || event.Record.SeriesTitle != "The Show" { t.Fatalf("unexpected record: %#v", event.Record) diff --git a/internal/webhooksync/repo.go b/internal/webhooksync/repo.go index 523e0305..9e17c920 100644 --- a/internal/webhooksync/repo.go +++ b/internal/webhooksync/repo.go @@ -50,9 +50,9 @@ func (r *Repository) ListConnections(ctx context.Context, userID int) ([]Connect c.webhook_secret, c.account_discovery_available, c.last_webhook_received_at, c.last_webhook_error_at, COALESCE(c.last_webhook_error_message, ''), c.created_at, c.updated_at, - COUNT(a.id)::integer AS actor_count + COUNT(m.id)::integer AS user_count FROM webhook_sync_connections c - LEFT JOIN webhook_sync_actor_mappings a ON a.connection_id = c.id + LEFT JOIN webhook_sync_profile_mappings m ON m.connection_id = c.id WHERE c.user_id = $1 GROUP BY c.id ORDER BY c.created_at DESC`, userID) @@ -68,7 +68,7 @@ func (r *Repository) ListConnections(ctx context.Context, userID int) ([]Connect &c.ID, &c.UserID, &c.Provider, &c.ServerID, &c.ServerName, &c.BaseURL, &c.AccessToken, &c.DefaultProfileID, &c.WebhookSecret, &c.AccountDiscoveryAvailable, &c.LastWebhookReceivedAt, &c.LastWebhookErrorAt, &c.LastWebhookErrorMessage, - &c.CreatedAt, &c.UpdatedAt, &c.ActorCount, + &c.CreatedAt, &c.UpdatedAt, &c.UserCount, ); err != nil { return nil, fmt.Errorf("scanning webhook sync connection: %w", err) } @@ -170,22 +170,22 @@ func (r *Repository) SetDiscoveryAvailable(ctx context.Context, connectionID str return nil } -func (r *Repository) ListMappings(ctx context.Context, connectionID string) ([]ActorMapping, error) { +func (r *Repository) ListMappings(ctx context.Context, connectionID string) ([]ProfileMapping, error) { rows, err := r.pool.Query(ctx, ` - SELECT id, connection_id, external_actor_id, external_actor_name, silo_profile_id, + SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at, created_at, updated_at - FROM webhook_sync_actor_mappings + FROM webhook_sync_profile_mappings WHERE connection_id = $1 - ORDER BY external_actor_name ASC, id ASC`, connectionID) + ORDER BY external_user_name ASC, id ASC`, connectionID) if err != nil { return nil, fmt.Errorf("listing webhook sync mappings: %w", err) } defer rows.Close() - var out []ActorMapping + var out []ProfileMapping for rows.Next() { - var m ActorMapping + var m ProfileMapping if err := rows.Scan( - &m.ID, &m.ConnectionID, &m.ExternalActorID, &m.ExternalActorName, &m.SiloProfileID, + &m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID, &m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scanning webhook sync mapping: %w", err) @@ -198,15 +198,15 @@ func (r *Repository) ListMappings(ctx context.Context, connectionID string) ([]A return out, nil } -func (r *Repository) GetMappingByActor(ctx context.Context, connectionID, actorID string) (*ActorMapping, error) { +func (r *Repository) GetMappingByUser(ctx context.Context, connectionID, externalUserID string) (*ProfileMapping, error) { row := r.pool.QueryRow(ctx, ` - SELECT id, connection_id, external_actor_id, external_actor_name, silo_profile_id, + SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at, created_at, updated_at - FROM webhook_sync_actor_mappings - WHERE connection_id = $1 AND external_actor_id = $2`, connectionID, actorID) - var m ActorMapping + FROM webhook_sync_profile_mappings + WHERE connection_id = $1 AND external_user_id = $2`, connectionID, externalUserID) + var m ProfileMapping if err := row.Scan( - &m.ID, &m.ConnectionID, &m.ExternalActorID, &m.ExternalActorName, &m.SiloProfileID, + &m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID, &m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -217,30 +217,30 @@ func (r *Repository) GetMappingByActor(ctx context.Context, connectionID, actorI return &m, nil } -func (r *Repository) ReplaceMappings(ctx context.Context, connectionID string, mappings []UpdateActorMapping) ([]ActorMapping, error) { +func (r *Repository) ReplaceMappings(ctx context.Context, connectionID string, mappings []UpdateProfileMapping) ([]ProfileMapping, error) { tx, err := r.pool.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin replace webhook mappings: %w", err) } defer tx.Rollback(ctx) //nolint:errcheck - if _, err := tx.Exec(ctx, `DELETE FROM webhook_sync_actor_mappings WHERE connection_id = $1`, connectionID); err != nil { + if _, err := tx.Exec(ctx, `DELETE FROM webhook_sync_profile_mappings WHERE connection_id = $1`, connectionID); err != nil { return nil, fmt.Errorf("deleting webhook sync mappings: %w", err) } - out := make([]ActorMapping, 0, len(mappings)) + out := make([]ProfileMapping, 0, len(mappings)) for _, input := range mappings { row := tx.QueryRow(ctx, ` - INSERT INTO webhook_sync_actor_mappings ( - connection_id, external_actor_id, external_actor_name, silo_profile_id, last_seen_at + INSERT INTO webhook_sync_profile_mappings ( + connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at ) VALUES ($1, $2, $3, $4, NOW()) - RETURNING id, connection_id, external_actor_id, external_actor_name, silo_profile_id, + RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at, created_at, updated_at`, - connectionID, input.ExternalActorID, input.ExternalActorName, input.SiloProfileID, + connectionID, input.ExternalUserID, input.ExternalUserName, input.SiloProfileID, ) - var m ActorMapping + var m ProfileMapping if err := row.Scan( - &m.ID, &m.ConnectionID, &m.ExternalActorID, &m.ExternalActorName, &m.SiloProfileID, + &m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID, &m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt, ); err != nil { return nil, fmt.Errorf("creating webhook sync mapping: %w", err) @@ -254,18 +254,18 @@ func (r *Repository) ReplaceMappings(ctx context.Context, connectionID string, m return out, nil } -func (r *Repository) CreateDefaultMapping(ctx context.Context, connectionID, actorID, actorName, profileID string) (*ActorMapping, error) { +func (r *Repository) CreateDefaultMapping(ctx context.Context, connectionID, externalUserID, externalUserName, profileID string) (*ProfileMapping, error) { row := r.pool.QueryRow(ctx, ` - INSERT INTO webhook_sync_actor_mappings ( - connection_id, external_actor_id, external_actor_name, silo_profile_id, last_seen_at + INSERT INTO webhook_sync_profile_mappings ( + connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at ) VALUES ($1, $2, $3, $4, NOW()) - RETURNING id, connection_id, external_actor_id, external_actor_name, silo_profile_id, + RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at, created_at, updated_at`, - connectionID, actorID, actorName, &profileID, + connectionID, externalUserID, externalUserName, &profileID, ) - var m ActorMapping + var m ProfileMapping if err := row.Scan( - &m.ID, &m.ConnectionID, &m.ExternalActorID, &m.ExternalActorName, &m.SiloProfileID, + &m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID, &m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt, ); err != nil { return nil, fmt.Errorf("creating default webhook mapping: %w", err) @@ -273,34 +273,34 @@ func (r *Repository) CreateDefaultMapping(ctx context.Context, connectionID, act return &m, nil } -func (r *Repository) UpsertSeenActor(ctx context.Context, connectionID, actorID, actorName string) error { +func (r *Repository) UpsertSeenUser(ctx context.Context, connectionID, externalUserID, externalUserName string) error { _, err := r.pool.Exec(ctx, ` - INSERT INTO webhook_sync_actor_mappings ( - connection_id, external_actor_id, external_actor_name, last_seen_at + INSERT INTO webhook_sync_profile_mappings ( + connection_id, external_user_id, external_user_name, last_seen_at ) VALUES ($1, $2, $3, NOW()) - ON CONFLICT (connection_id, external_actor_id) DO UPDATE SET - external_actor_name = EXCLUDED.external_actor_name, + ON CONFLICT (connection_id, external_user_id) DO UPDATE SET + external_user_name = EXCLUDED.external_user_name, last_seen_at = NOW(), updated_at = NOW()`, - connectionID, actorID, actorName, + connectionID, externalUserID, externalUserName, ) if err != nil { - return fmt.Errorf("upserting seen actor: %w", err) + return fmt.Errorf("upserting seen external user: %w", err) } return nil } -func (r *Repository) GetItemState(ctx context.Context, connectionID, externalActorID, externalItemID string) (*ItemState, error) { +func (r *Repository) GetItemState(ctx context.Context, connectionID, externalUserID, externalItemID string) (*ItemState, error) { row := r.pool.QueryRow(ctx, ` - SELECT connection_id, external_actor_id, external_item_id, COALESCE(media_item_id, ''), + SELECT connection_id, external_user_id, external_item_id, COALESCE(media_item_id, ''), last_event_at, last_completed, last_position_seconds, updated_at FROM webhook_sync_item_state - WHERE connection_id = $1 AND external_actor_id = $2 AND external_item_id = $3`, - connectionID, externalActorID, externalItemID, + WHERE connection_id = $1 AND external_user_id = $2 AND external_item_id = $3`, + connectionID, externalUserID, externalItemID, ) var state ItemState if err := row.Scan( - &state.ConnectionID, &state.ExternalActorID, &state.ExternalItemID, &state.MediaItemID, + &state.ConnectionID, &state.ExternalUserID, &state.ExternalItemID, &state.MediaItemID, &state.LastEventAt, &state.LastCompleted, &state.LastPositionSecond, &state.UpdatedAt, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -314,16 +314,16 @@ func (r *Repository) GetItemState(ctx context.Context, connectionID, externalAct func (r *Repository) UpsertItemState(ctx context.Context, state ItemState) error { _, err := r.pool.Exec(ctx, ` INSERT INTO webhook_sync_item_state ( - connection_id, external_actor_id, external_item_id, media_item_id, + connection_id, external_user_id, external_item_id, media_item_id, last_event_at, last_completed, last_position_seconds, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) - ON CONFLICT (connection_id, external_actor_id, external_item_id) DO UPDATE SET + ON CONFLICT (connection_id, external_user_id, external_item_id) DO UPDATE SET media_item_id = EXCLUDED.media_item_id, last_event_at = EXCLUDED.last_event_at, last_completed = EXCLUDED.last_completed, last_position_seconds = EXCLUDED.last_position_seconds, updated_at = NOW()`, - state.ConnectionID, state.ExternalActorID, state.ExternalItemID, state.MediaItemID, + state.ConnectionID, state.ExternalUserID, state.ExternalItemID, state.MediaItemID, state.LastEventAt, state.LastCompleted, state.LastPositionSecond, ) if err != nil { diff --git a/internal/webhooksync/service.go b/internal/webhooksync/service.go index f74cfe01..596dcaf7 100644 --- a/internal/webhooksync/service.go +++ b/internal/webhooksync/service.go @@ -64,7 +64,7 @@ func (s *Service) ListConnections(ctx context.Context, userID int) ([]Connection if providerErr != nil { continue } - if _, available, err := provider.DiscoverActors(ctx, &connections[i], nil); err == nil { + if _, available, err := provider.DiscoverUsers(ctx, &connections[i], nil); err == nil { connections[i].AccountDiscoveryAvailable = available if err := s.repo.SetDiscoveryAvailable(ctx, connections[i].ID, available); err != nil { slog.Warn("webhook sync: failed to persist discovery availability", "connection_id", connections[i].ID, "error", err) @@ -107,10 +107,10 @@ func (s *Service) CreateConnection(ctx context.Context, userID int, input Create if err != nil { return nil, err } - if actorID, actorName, ok, err := provider.DefaultActor(ctx, conn, input); err != nil { + if userID, userName, ok, err := provider.DefaultUser(ctx, conn, input); err != nil { return nil, err } else if ok { - if _, err := s.repo.CreateDefaultMapping(ctx, conn.ID, actorID, actorName, input.DefaultProfileID); err != nil { + if _, err := s.repo.CreateDefaultMapping(ctx, conn.ID, userID, userName, input.DefaultProfileID); err != nil { return nil, err } } @@ -152,7 +152,7 @@ func (s *Service) RotateWebhook(ctx context.Context, userID int, id, baseURL str return &RotateWebhookResult{WebhookURL: buildWebhookURL(baseURL, secret)}, nil } -func (s *Service) GetActors(ctx context.Context, userID int, id string) (*ActorMappingsResponse, error) { +func (s *Service) GetProfileMappings(ctx context.Context, userID int, id string) (*ProfileMappingsResponse, error) { conn, err := s.repo.GetConnection(ctx, userID, id) if err != nil { return nil, err @@ -165,24 +165,24 @@ func (s *Service) GetActors(ctx context.Context, userID int, id string) (*ActorM if err != nil { return nil, err } - discovered, available, discoverErr := provider.DiscoverActors(ctx, conn, mappings) + discovered, available, discoverErr := provider.DiscoverUsers(ctx, conn, mappings) if discoverErr == nil { conn.AccountDiscoveryAvailable = available _ = s.repo.SetDiscoveryAvailable(ctx, conn.ID, available) } else { - discovered = mappingsToDiscoveredActors(mappings) + discovered = mappingsToDiscoveredUsers(mappings) } if len(discovered) == 0 { - discovered = mappingsToDiscoveredActors(mappings) + discovered = mappingsToDiscoveredUsers(mappings) } - return &ActorMappingsResponse{ + return &ProfileMappingsResponse{ Mappings: mappings, - DiscoveredActors: discovered, + DiscoveredUsers: discovered, AccountDiscoveryAvailable: conn.AccountDiscoveryAvailable, }, nil } -func (s *Service) UpdateActors(ctx context.Context, userID int, id string, input UpdateActorMappingsInput) ([]ActorMapping, error) { +func (s *Service) UpdateProfileMappings(ctx context.Context, userID int, id string, input UpdateProfileMappingsInput) ([]ProfileMapping, error) { conn, err := s.repo.GetConnection(ctx, userID, id) if err != nil { return nil, err @@ -238,8 +238,8 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req if event != nil { result.EventKind = event.EventKind result.Action = event.Action - result.ActorID = event.ActorID - result.ActorName = event.ActorName + result.UserID = event.UserID + result.UserName = event.UserName result.ExternalItemID = event.ExternalItemID result.MediaKind = event.MediaKind } @@ -251,20 +251,20 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req result.Summary = webhookResultSummary(event, "Ignored unsupported webhook event") return result, nil } - if err := s.repo.UpsertSeenActor(ctx, conn.ID, event.ActorID, event.ActorName); err != nil { - slog.Warn("webhook sync: failed to upsert seen actor", "connection_id", conn.ID, "actor_id", event.ActorID, "error", err) + if err := s.repo.UpsertSeenUser(ctx, conn.ID, event.UserID, event.UserName); err != nil { + slog.Warn("webhook sync: failed to upsert seen external user", "connection_id", conn.ID, "external_user_id", event.UserID, "error", err) } profileID := conn.DefaultProfileID - if mapping, err := s.repo.GetMappingByActor(ctx, conn.ID, event.ActorID); err != nil { - return s.failWebhook(ctx, conn.ID, result, err, "Failed to resolve actor routing") + if mapping, err := s.repo.GetMappingByUser(ctx, conn.ID, event.UserID); err != nil { + return s.failWebhook(ctx, conn.ID, result, err, "Failed to resolve profile mapping") } else if mapping != nil && mapping.SiloProfileID != nil && *mapping.SiloProfileID != "" { profileID = *mapping.SiloProfileID } result.ProfileID = profileID if profileID == "" { result.Outcome = OutcomeSkipped - result.Summary = "Skipped because no default or actor-specific profile is configured" + result.Summary = "Skipped because no default or user-specific profile is configured" return result, nil } @@ -283,7 +283,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req switch event.Action { case ActionMarkUnplayed: - if state, err := s.repo.GetItemState(ctx, conn.ID, event.ActorID, event.ExternalItemID); err != nil { + if state, err := s.repo.GetItemState(ctx, conn.ID, event.UserID, event.ExternalItemID); err != nil { return s.failWebhook(ctx, conn.ID, result, err, "Failed to load existing item state") } else if state != nil && !event.OccurredAt.After(state.LastEventAt) { result.Outcome = OutcomeSkipped @@ -295,7 +295,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req } if err := s.repo.UpsertItemState(ctx, ItemState{ ConnectionID: conn.ID, - ExternalActorID: event.ActorID, + ExternalUserID: event.UserID, ExternalItemID: event.ExternalItemID, MediaItemID: match.MediaItemID, LastEventAt: event.OccurredAt, @@ -342,7 +342,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req return result, nil } - state, err := s.repo.GetItemState(ctx, conn.ID, event.ActorID, event.ExternalItemID) + state, err := s.repo.GetItemState(ctx, conn.ID, event.UserID, event.ExternalItemID) if err != nil { return s.failWebhook(ctx, conn.ID, result, err, "Failed to load existing item state") } @@ -369,7 +369,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req if err := s.repo.UpsertItemState(ctx, ItemState{ ConnectionID: conn.ID, - ExternalActorID: event.ActorID, + ExternalUserID: event.UserID, ExternalItemID: event.ExternalItemID, MediaItemID: match.MediaItemID, LastEventAt: event.OccurredAt, @@ -402,15 +402,15 @@ func webhookResultSummary(event *CanonicalEvent, fallback string) string { return fallback } -func mappingsToDiscoveredActors(mappings []ActorMapping) []DiscoveredActor { +func mappingsToDiscoveredUsers(mappings []ProfileMapping) []DiscoveredUser { if len(mappings) == 0 { return nil } - out := make([]DiscoveredActor, 0, len(mappings)) + out := make([]DiscoveredUser, 0, len(mappings)) for _, mapping := range mappings { - out = append(out, DiscoveredActor{ - ExternalActorID: mapping.ExternalActorID, - ExternalActorName: mapping.ExternalActorName, + out = append(out, DiscoveredUser{ + ExternalUserID: mapping.ExternalUserID, + ExternalUserName: mapping.ExternalUserName, }) } return out @@ -481,7 +481,7 @@ func (r CanonicalRecord) toHistoryImportRecord() historyimport.Record { type Provider interface { ID() string ValidateCreateInput(input CreateConnectionInput) error - DefaultActor(ctx context.Context, conn *Connection, input CreateConnectionInput) (actorID string, actorName string, ok bool, err error) - DiscoverActors(ctx context.Context, conn *Connection, mappings []ActorMapping) ([]DiscoveredActor, bool, error) + DefaultUser(ctx context.Context, conn *Connection, input CreateConnectionInput) (externalUserID string, externalUserName string, ok bool, err error) + DiscoverUsers(ctx context.Context, conn *Connection, mappings []ProfileMapping) ([]DiscoveredUser, bool, error) ParseWebhook(ctx context.Context, conn *Connection, r *http.Request) (*CanonicalEvent, error) } diff --git a/internal/webhooksync/service_test.go b/internal/webhooksync/service_test.go index 894819fa..e2e0ecf0 100644 --- a/internal/webhooksync/service_test.go +++ b/internal/webhooksync/service_test.go @@ -52,8 +52,8 @@ func TestFilterDiscoveredAccounts(t *testing.T) { {ID: "3", Name: "Friend"}, {ID: "4", Name: "Mapped Friend"}, } - mappings := []ActorMapping{ - {ExternalActorID: "4"}, + mappings := []ProfileMapping{ + {ExternalUserID: "4"}, } filtered := filterDiscoveredAccounts(accounts, mappings) diff --git a/internal/webhooksync/types.go b/internal/webhooksync/types.go index a2224b10..aaea68a8 100644 --- a/internal/webhooksync/types.go +++ b/internal/webhooksync/types.go @@ -22,7 +22,7 @@ type Connection struct { AccessToken string `json:"-"` WebhookSecret string `json:"-"` AccountDiscoveryAvailable bool `json:"account_discovery_available"` - ActorCount int `json:"actor_count"` + UserCount int `json:"user_count"` LastWebhookReceivedAt *time.Time `json:"last_webhook_received_at,omitempty"` LastWebhookErrorAt *time.Time `json:"last_webhook_error_at,omitempty"` LastWebhookErrorMessage string `json:"last_webhook_error_message,omitempty"` @@ -30,25 +30,25 @@ type Connection struct { UpdatedAt time.Time `json:"updated_at"` } -type ActorMapping struct { - ID int `json:"id"` - ConnectionID string `json:"connection_id"` - ExternalActorID string `json:"external_actor_id"` - ExternalActorName string `json:"external_actor_name"` - SiloProfileID *string `json:"silo_profile_id,omitempty"` - LastSeenAt time.Time `json:"last_seen_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +type ProfileMapping struct { + ID int `json:"id"` + ConnectionID string `json:"connection_id"` + ExternalUserID string `json:"external_user_id"` + ExternalUserName string `json:"external_user_name"` + SiloProfileID *string `json:"silo_profile_id,omitempty"` + LastSeenAt time.Time `json:"last_seen_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -type DiscoveredActor struct { - ExternalActorID string `json:"external_actor_id"` - ExternalActorName string `json:"external_actor_name"` +type DiscoveredUser struct { + ExternalUserID string `json:"external_user_id"` + ExternalUserName string `json:"external_user_name"` } type ItemState struct { ConnectionID string - ExternalActorID string + ExternalUserID string ExternalItemID string MediaItemID string LastEventAt time.Time @@ -80,20 +80,20 @@ type RotateWebhookResult struct { WebhookURL string `json:"webhook_url"` } -type ActorMappingsResponse struct { - Mappings []ActorMapping `json:"mappings"` - DiscoveredActors []DiscoveredActor `json:"discovered_actors"` - AccountDiscoveryAvailable bool `json:"account_discovery_available"` +type ProfileMappingsResponse struct { + Mappings []ProfileMapping `json:"mappings"` + DiscoveredUsers []DiscoveredUser `json:"discovered_users"` + AccountDiscoveryAvailable bool `json:"account_discovery_available"` } -type UpdateActorMappingsInput struct { - Mappings []UpdateActorMapping `json:"mappings"` +type UpdateProfileMappingsInput struct { + Mappings []UpdateProfileMapping `json:"mappings"` } -type UpdateActorMapping struct { - ExternalActorID string `json:"external_actor_id"` - ExternalActorName string `json:"external_actor_name"` - SiloProfileID *string `json:"silo_profile_id"` +type UpdateProfileMapping struct { + ExternalUserID string `json:"external_user_id"` + ExternalUserName string `json:"external_user_name"` + SiloProfileID *string `json:"silo_profile_id"` } type CanonicalEvent struct { @@ -102,8 +102,8 @@ type CanonicalEvent struct { OccurredAt time.Time Action string EventKind string - ActorID string - ActorName string + UserID string + UserName string ExternalItemID string MediaKind string Completed bool @@ -158,8 +158,8 @@ type ProcessWebhookResult struct { Provider string EventKind string Action string - ActorID string - ActorName string + UserID string + UserName string ExternalItemID string MediaKind string MatchedMediaItemID string diff --git a/migrations/135_webhook_sync_silo_profile_column.down.sql b/migrations/135_webhook_sync_silo_profile_column.down.sql new file mode 100644 index 00000000..136aae0b --- /dev/null +++ b/migrations/135_webhook_sync_silo_profile_column.down.sql @@ -0,0 +1,30 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_sync_actor_mappings' + AND column_name = 'silo_profile_id' + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_sync_actor_mappings' + AND column_name = 'continuum_profile_id' + ) THEN + ALTER TABLE public.webhook_sync_actor_mappings + RENAME COLUMN silo_profile_id TO continuum_profile_id; + ELSIF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_sync_actor_mappings' + AND column_name = 'silo_profile_id' + ) THEN + UPDATE public.webhook_sync_actor_mappings + SET continuum_profile_id = COALESCE(continuum_profile_id, silo_profile_id) + WHERE continuum_profile_id IS NULL + AND silo_profile_id IS NOT NULL; + END IF; +END $$; diff --git a/migrations/135_webhook_sync_silo_profile_column.up.sql b/migrations/135_webhook_sync_silo_profile_column.up.sql new file mode 100644 index 00000000..d8ced74b --- /dev/null +++ b/migrations/135_webhook_sync_silo_profile_column.up.sql @@ -0,0 +1,30 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_sync_actor_mappings' + AND column_name = 'continuum_profile_id' + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_sync_actor_mappings' + AND column_name = 'silo_profile_id' + ) THEN + ALTER TABLE public.webhook_sync_actor_mappings + RENAME COLUMN continuum_profile_id TO silo_profile_id; + ELSIF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_sync_actor_mappings' + AND column_name = 'continuum_profile_id' + ) THEN + UPDATE public.webhook_sync_actor_mappings + SET silo_profile_id = COALESCE(silo_profile_id, continuum_profile_id) + WHERE silo_profile_id IS NULL + AND continuum_profile_id IS NOT NULL; + END IF; +END $$; diff --git a/migrations/136_webhook_sync_rename_actor_to_profile.down.sql b/migrations/136_webhook_sync_rename_actor_to_profile.down.sql new file mode 100644 index 00000000..5717e11d --- /dev/null +++ b/migrations/136_webhook_sync_rename_actor_to_profile.down.sql @@ -0,0 +1,26 @@ +UPDATE public.webhook_sync_event_logs +SET attrs = (attrs - 'external_user_id' - 'external_user_name') + || jsonb_strip_nulls(jsonb_build_object( + 'actor_id', attrs->'external_user_id', + 'actor_name', attrs->'external_user_name' + )) +WHERE attrs ? 'external_user_id' OR attrs ? 'external_user_name'; + +ALTER TABLE public.webhook_sync_item_state + RENAME COLUMN external_user_id TO external_actor_id; + +ALTER INDEX public.idx_webhook_sync_profile_mappings_profile + RENAME TO idx_webhook_sync_actor_mappings_profile; +ALTER INDEX public.idx_webhook_sync_profile_mappings_connection + RENAME TO idx_webhook_sync_actor_mappings_connection; + +ALTER TABLE public.webhook_sync_profile_mappings + RENAME CONSTRAINT webhook_sync_profile_mappings_connection_user_unique + TO webhook_sync_actor_mappings_connection_actor_unique; + +ALTER TABLE public.webhook_sync_profile_mappings + RENAME COLUMN external_user_name TO external_actor_name; +ALTER TABLE public.webhook_sync_profile_mappings + RENAME COLUMN external_user_id TO external_actor_id; + +ALTER TABLE public.webhook_sync_profile_mappings RENAME TO webhook_sync_actor_mappings; diff --git a/migrations/136_webhook_sync_rename_actor_to_profile.up.sql b/migrations/136_webhook_sync_rename_actor_to_profile.up.sql new file mode 100644 index 00000000..774b67ed --- /dev/null +++ b/migrations/136_webhook_sync_rename_actor_to_profile.up.sql @@ -0,0 +1,26 @@ +ALTER TABLE public.webhook_sync_actor_mappings RENAME TO webhook_sync_profile_mappings; + +ALTER TABLE public.webhook_sync_profile_mappings + RENAME COLUMN external_actor_id TO external_user_id; +ALTER TABLE public.webhook_sync_profile_mappings + RENAME COLUMN external_actor_name TO external_user_name; + +ALTER TABLE public.webhook_sync_profile_mappings + RENAME CONSTRAINT webhook_sync_actor_mappings_connection_actor_unique + TO webhook_sync_profile_mappings_connection_user_unique; + +ALTER INDEX public.idx_webhook_sync_actor_mappings_connection + RENAME TO idx_webhook_sync_profile_mappings_connection; +ALTER INDEX public.idx_webhook_sync_actor_mappings_profile + RENAME TO idx_webhook_sync_profile_mappings_profile; + +ALTER TABLE public.webhook_sync_item_state + RENAME COLUMN external_actor_id TO external_user_id; + +UPDATE public.webhook_sync_event_logs +SET attrs = (attrs - 'actor_id' - 'actor_name') + || jsonb_strip_nulls(jsonb_build_object( + 'external_user_id', attrs->'actor_id', + 'external_user_name', attrs->'actor_name' + )) +WHERE attrs ? 'actor_id' OR attrs ? 'actor_name'; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3c13f064..521c5cc2 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -231,7 +231,7 @@ export interface WebhookSyncConnection { server_name: string; default_profile_id: string; webhook_url?: string; - actor_count?: number; + user_count?: number; account_discovery_available?: boolean; last_webhook_received_at?: string | null; last_webhook_error_at?: string | null; @@ -253,25 +253,25 @@ export interface WebhookSyncEventLog { attrs?: Record; } -export interface WebhookSyncActorMapping { +export interface WebhookSyncProfileMapping { id: number; connection_id?: string; - external_actor_id: string; - external_actor_name: string; + external_user_id: string; + external_user_name: string; silo_profile_id?: string | null; last_seen_at?: string; created_at?: string; updated_at?: string; } -export interface WebhookSyncDiscoveredActor { - external_actor_id: string; - external_actor_name: string; +export interface WebhookSyncDiscoveredUser { + external_user_id: string; + external_user_name: string; } -export interface WebhookSyncActorsResponse { - mappings: WebhookSyncActorMapping[]; - discovered_actors?: WebhookSyncDiscoveredActor[]; +export interface WebhookSyncProfileMappingsResponse { + mappings: WebhookSyncProfileMapping[]; + discovered_users?: WebhookSyncDiscoveredUser[]; account_discovery_available?: boolean; } @@ -304,10 +304,10 @@ export interface UpdateWebhookSyncConnectionRequest { default_profile_id?: string; } -export interface UpdateWebhookSyncActorsRequest { +export interface UpdateWebhookSyncProfileMappingsRequest { mappings: Array<{ - external_actor_id: string; - external_actor_name: string; + external_user_id: string; + external_user_name: string; silo_profile_id: string | null; }>; } diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index b058ac2a..e386695f 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -204,7 +204,8 @@ export const webhookSyncKeys = { all: ["webhook-sync"] as const, connections: () => ["webhook-sync", "connections"] as const, events: (connectionId?: string) => ["webhook-sync", "events", connectionId] as const, - actors: (connectionId?: string) => ["webhook-sync", "actors", connectionId] as const, + profileMappings: (connectionId?: string) => + ["webhook-sync", "profile-mappings", connectionId] as const, connection: (connectionId?: string) => ["webhook-sync", "connection", connectionId] as const, }; diff --git a/web/src/hooks/queries/webhook-sync.ts b/web/src/hooks/queries/webhook-sync.ts index cecb1e7b..52e41ce7 100644 --- a/web/src/hooks/queries/webhook-sync.ts +++ b/web/src/hooks/queries/webhook-sync.ts @@ -4,11 +4,11 @@ import type { CreateWebhookSyncConnectionRequest, CreateWebhookSyncConnectionResponse, RotateWebhookSyncWebhookResponse, - UpdateWebhookSyncActorsRequest, UpdateWebhookSyncConnectionRequest, - WebhookSyncActorsResponse, + UpdateWebhookSyncProfileMappingsRequest, WebhookSyncConnection, WebhookSyncEventLog, + WebhookSyncProfileMappingsResponse, } from "@/api/types"; import { webhookSyncKeys } from "./keys"; import { toast } from "sonner"; @@ -32,7 +32,9 @@ export function useCreateWebhookSyncConnection() { onSuccess: (result) => { toast.success("Webhook connection created"); queryClient.invalidateQueries({ queryKey: webhookSyncKeys.connections() }); - queryClient.invalidateQueries({ queryKey: webhookSyncKeys.actors(result.connection.id) }); + queryClient.invalidateQueries({ + queryKey: webhookSyncKeys.profileMappings(result.connection.id), + }); }, onError: (err) => { toast.error(err instanceof Error ? err.message : "Failed to create webhook connection"); @@ -105,17 +107,17 @@ export function useRotateWebhookSyncWebhook() { }); } -export function useWebhookSyncActors(connectionId?: string) { +export function useWebhookSyncProfileMappings(connectionId?: string) { return useQuery({ - queryKey: webhookSyncKeys.actors(connectionId), + queryKey: webhookSyncKeys.profileMappings(connectionId), queryFn: () => - api(`/webhook-sync/connections/${connectionId}/actors`).then( - (d) => ({ - mappings: d?.mappings ?? [], - discovered_actors: d?.discovered_actors ?? [], - account_discovery_available: d?.account_discovery_available ?? false, - }), - ), + api( + `/webhook-sync/connections/${connectionId}/profile-mappings`, + ).then((d) => ({ + mappings: d?.mappings ?? [], + discovered_users: d?.discovered_users ?? [], + account_discovery_available: d?.account_discovery_available ?? false, + })), enabled: !!connectionId, staleTime: 10_000, }); @@ -134,7 +136,7 @@ export function useWebhookSyncEvents(connectionId?: string) { }); } -export function useUpdateWebhookSyncActors() { +export function useUpdateWebhookSyncProfileMappings() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ @@ -142,21 +144,21 @@ export function useUpdateWebhookSyncActors() { body, }: { connectionId: string; - body: UpdateWebhookSyncActorsRequest; + body: UpdateWebhookSyncProfileMappingsRequest; }) => - api(`/webhook-sync/connections/${connectionId}/actors`, { + api(`/webhook-sync/connections/${connectionId}/profile-mappings`, { method: "PUT", body: JSON.stringify(body), }), onSuccess: (_, variables) => { - toast.success("Actor routing saved"); + toast.success("Profile mappings saved"); queryClient.invalidateQueries({ - queryKey: webhookSyncKeys.actors(variables.connectionId), + queryKey: webhookSyncKeys.profileMappings(variables.connectionId), }); queryClient.invalidateQueries({ queryKey: webhookSyncKeys.connections() }); }, onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to save actor routing"); + toast.error(err instanceof Error ? err.message : "Failed to save profile mappings"); }, }); } diff --git a/web/src/pages/settings/WebhookSyncSettings.tsx b/web/src/pages/settings/WebhookSyncSettings.tsx index 5b2654ac..d85f2dab 100644 --- a/web/src/pages/settings/WebhookSyncSettings.tsx +++ b/web/src/pages/settings/WebhookSyncSettings.tsx @@ -48,20 +48,20 @@ import { import { cn } from "@/lib/utils"; import { toast } from "sonner"; import type { - WebhookSyncActorMapping, WebhookSyncConnection, - WebhookSyncDiscoveredActor, + WebhookSyncDiscoveredUser, WebhookSyncEventLog, + WebhookSyncProfileMapping, } from "@/api/types"; import { useCreateWebhookSyncConnection, useDeleteWebhookSyncConnection, useRotateWebhookSyncWebhook, useUpdateWebhookSyncConnection, - useUpdateWebhookSyncActors, - useWebhookSyncActors, + useUpdateWebhookSyncProfileMappings, useWebhookSyncConnections, useWebhookSyncEvents, + useWebhookSyncProfileMappings, } from "@/hooks/queries/webhook-sync"; import { buildPlexAuthURL, @@ -190,11 +190,11 @@ function eventAttrString(event: WebhookSyncEventLog, key: string) { return ""; } -function eventActorLabel(event: WebhookSyncEventLog) { - const actorName = eventAttrString(event, "actor_name"); - const actorID = eventAttrString(event, "actor_id"); - if (actorName && actorID) return `${actorName} (${actorID})`; - return actorName || actorID || "Unknown actor"; +function eventUserLabel(event: WebhookSyncEventLog) { + const userName = eventAttrString(event, "external_user_name"); + const userID = eventAttrString(event, "external_user_id"); + if (userName && userID) return `${userName} (${userID})`; + return userName || userID || "Unknown user"; } function eventMatchedItemLabel(event: WebhookSyncEventLog) { @@ -204,8 +204,8 @@ function eventMatchedItemLabel(event: WebhookSyncEventLog) { const EVENT_ATTR_LABELS: Record = { event_kind: "Event kind", action: "Action", - actor_id: "Actor ID", - actor_name: "Actor", + external_user_id: "External user ID", + external_user_name: "External user", external_item_id: "External item ID", media_kind: "Media kind", matched_media_item_id: "Matched item ID", @@ -224,6 +224,46 @@ function eventAttrLabel(key: string) { type ProviderType = "plex" | "emby" | "jellyfin"; type ConnectionDraft = { serverName: string; defaultProfileId: string }; +type EventMatrixTone = "required" | "recommended" | "skip"; +type EventMatrixSection = { + label: string; + tone: EventMatrixTone; + items: { event: string; note: string }[]; +}; + +const EVENT_MATRIX_TONE: Record = { + required: "text-emerald-300", + recommended: "text-sky-300", + skip: "text-muted-foreground", +}; + +function EventMatrix({ sections }: { sections: EventMatrixSection[] }) { + return ( +
+ {sections.map((section) => ( +
+

+ {section.label} +

+
    + {section.items.map((item) => ( +
  • + {item.event} + — {item.note} +
  • + ))} +
+
+ ))} +
+ ); +} + export default function WebhookSyncSettings() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -234,7 +274,7 @@ export default function WebhookSyncSettings() { const deleteConnectionMutation = useDeleteWebhookSyncConnection(); const rotateWebhookMutation = useRotateWebhookSyncWebhook(); const updateConnectionMutation = useUpdateWebhookSyncConnection(); - const updateActorsMutation = useUpdateWebhookSyncActors(); + const updateMappingsMutation = useUpdateWebhookSyncProfileMappings(); const [provider, setProvider] = useState("plex"); const [manualServerName, setManualServerName] = useState(""); @@ -246,7 +286,7 @@ export default function WebhookSyncSettings() { const [connectionDraftsById, setConnectionDraftsById] = useState>( {}, ); - const [actorDraftsByConnection, setActorDraftsByConnection] = useState< + const [mappingDraftsByConnection, setMappingDraftsByConnection] = useState< Record> >({}); const [plexAuthPending, setPlexAuthPending] = useState(false); @@ -265,11 +305,11 @@ export default function WebhookSyncSettings() { }, [connections, selectedConnectionId]); const currentConnectionId = selectedConnection?.id ?? ""; - const actorsQuery = useWebhookSyncActors(currentConnectionId || undefined); + const mappingsQuery = useWebhookSyncProfileMappings(currentConnectionId || undefined); const eventsQuery = useWebhookSyncEvents(currentConnectionId || undefined); const effectiveSelectedServerId = selectedServerId || plexServers[0]?.clientIdentifier || ""; const effectiveDefaultProfileId = defaultProfileId || profile?.id || profiles[0]?.id || ""; - const currentActorDrafts = actorDraftsByConnection[currentConnectionId] ?? {}; + const currentMappingDrafts = mappingDraftsByConnection[currentConnectionId] ?? {}; const currentConnectionDraft = currentConnectionId ? connectionDraftsById[currentConnectionId] : undefined; @@ -279,36 +319,36 @@ export default function WebhookSyncSettings() { [effectiveSelectedServerId, plexServers], ); - const actorRows = useMemo(() => { - const discoveredActors = actorsQuery.data?.discovered_actors ?? []; - const mappings = actorsQuery.data?.mappings ?? []; - const byActorId = new Map< + const mappingRows = useMemo(() => { + const discoveredUsers = mappingsQuery.data?.discovered_users ?? []; + const mappings = mappingsQuery.data?.mappings ?? []; + const byUserId = new Map< string, - { actor: WebhookSyncDiscoveredActor; mapping?: WebhookSyncActorMapping } + { user: WebhookSyncDiscoveredUser; mapping?: WebhookSyncProfileMapping } >(); - for (const actor of discoveredActors) { - byActorId.set(actor.external_actor_id, { actor }); + for (const user of discoveredUsers) { + byUserId.set(user.external_user_id, { user }); } for (const mapping of mappings) { - const existing = byActorId.get(mapping.external_actor_id); - byActorId.set(mapping.external_actor_id, { - actor: existing?.actor ?? { - external_actor_id: mapping.external_actor_id, - external_actor_name: mapping.external_actor_name, + const existing = byUserId.get(mapping.external_user_id); + byUserId.set(mapping.external_user_id, { + user: existing?.user ?? { + external_user_id: mapping.external_user_id, + external_user_name: mapping.external_user_name, }, mapping, }); } - return Array.from(byActorId.values()) - .map(({ actor, mapping }) => ({ - external_actor_id: actor.external_actor_id, - external_actor_name: actor.external_actor_name, + return Array.from(byUserId.values()) + .map(({ user, mapping }) => ({ + external_user_id: user.external_user_id, + external_user_name: user.external_user_name, silo_profile_id: mapping?.silo_profile_id ?? "", })) - .sort((a, b) => a.external_actor_name.localeCompare(b.external_actor_name)); - }, [actorsQuery.data]); + .sort((a, b) => a.external_user_name.localeCompare(b.external_user_name)); + }, [mappingsQuery.data]); const EVENTS_PER_PAGE = 15; @@ -322,7 +362,7 @@ export default function WebhookSyncSettings() { events = events.filter( (e) => e.summary.toLowerCase().includes(q) || - eventActorLabel(e).toLowerCase().includes(q) || + eventUserLabel(e).toLowerCase().includes(q) || (e.error_message ?? "").toLowerCase().includes(q), ); } @@ -485,19 +525,19 @@ export default function WebhookSyncSettings() { setSelectedConnectionId(result.connection.id); } - async function handleSaveActors() { + async function handleSaveMappings() { if (!currentConnectionId) { return; } - await updateActorsMutation.mutateAsync({ + await updateMappingsMutation.mutateAsync({ connectionId: currentConnectionId, body: { - mappings: actorRows.map((row) => { - const value = currentActorDrafts[row.external_actor_id] ?? row.silo_profile_id; + mappings: mappingRows.map((row) => { + const value = currentMappingDrafts[row.external_user_id] ?? row.silo_profile_id; return { - external_actor_id: row.external_actor_id, - external_actor_name: row.external_actor_name, + external_user_id: row.external_user_id, + external_user_name: row.external_user_name, silo_profile_id: !value || value === UNMAPPED_VALUE ? null : value, }; }), @@ -505,15 +545,15 @@ export default function WebhookSyncSettings() { }); } - function setDraftProfile(actorId: string, profileId: string) { + function setDraftProfile(externalUserId: string, profileId: string) { if (!currentConnectionId) { return; } - setActorDraftsByConnection((current) => ({ + setMappingDraftsByConnection((current) => ({ ...current, [currentConnectionId]: { ...(current[currentConnectionId] ?? {}), - [actorId]: profileId, + [externalUserId]: profileId, }, })); } @@ -678,7 +718,7 @@ export default function WebhookSyncSettings() { {/* ── Connected servers ── */} {connectionsQuery.isLoading ? (
@@ -724,8 +764,8 @@ export default function WebhookSyncSettings() {

- {connection.actor_count ?? 0} actor - {(connection.actor_count ?? 0) === 1 ? "" : "s"} + {connection.user_count ?? 0} user + {(connection.user_count ?? 0) === 1 ? "" : "s"} {connection.last_webhook_received_at ? ` · last event ${relativeTime(connection.last_webhook_received_at) ?? formatTimestamp(connection.last_webhook_received_at)}` : ""} @@ -896,51 +936,51 @@ export default function WebhookSyncSettings() {

- {/* Actor routing — part of connection config */} + {/* Profile mapping — part of connection config */}
- +

- Map external users to Silo profiles. Unmapped users fall back to the default - profile above. + Map each external user to a Silo profile. Unmapped users fall back to the + default profile above.

- {actorsQuery.isLoading ? ( + {mappingsQuery.isLoading ? (
- Loading actors... + Loading users...
- ) : actorsQuery.isError ? ( + ) : mappingsQuery.isError ? (

- {actorsQuery.error instanceof Error - ? actorsQuery.error.message - : "Failed to load actor routing"} + {mappingsQuery.error instanceof Error + ? mappingsQuery.error.message + : "Failed to load profile mappings"}

- ) : actorRows.length > 0 ? ( + ) : mappingRows.length > 0 ? (
- {actorRows.map((row) => ( + {mappingRows.map((row) => (
-

{row.external_actor_name}

+

{row.external_user_name}

- {row.external_actor_id} + {row.external_user_id}