From 5b37a5b00ae236b67ade5e863809897ddffa5669 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:31:58 -0700 Subject: [PATCH] adding tenant tag population --- backend/migrations/seedPivotTagCatalog.js | 33 ++----- backend/routes/pivotAdminRoutes.js | 26 ++++- backend/services/pivotTagCatalogService.js | 38 ++++++++ .../pivotAdminRoutes.outcomes.test.js | 28 +++++- .../tests/unit/pivotTagCatalogService.test.js | 19 ++++ .../PlatformAdmin/PivotLab/PivotLabPage.jsx | 45 +++++++++ .../PlatformAdmin/PivotLab/PivotLabPage.scss | 19 ++++ .../PivotTagCatalogPanel.jsx | 94 +++++++++++++++++++ .../PivotTagCatalogPanel.scss | 33 +++++++ .../TenantManagement/TenantManagementPage.jsx | 6 +- 10 files changed, 312 insertions(+), 29 deletions(-) create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotTagCatalogPanel/PivotTagCatalogPanel.jsx create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotTagCatalogPanel/PivotTagCatalogPanel.scss diff --git a/backend/migrations/seedPivotTagCatalog.js b/backend/migrations/seedPivotTagCatalog.js index c53edc35..5635dc53 100644 --- a/backend/migrations/seedPivotTagCatalog.js +++ b/backend/migrations/seedPivotTagCatalog.js @@ -9,41 +9,22 @@ require('dotenv').config(); const mongoose = require('mongoose'); const { connectToGlobalDatabase } = require('../connectionsManager'); -const pivotTagCatalogSchema = require('../schemas/pivotTagCatalog'); -const { getPivotTagCatalogSeedRows } = require('../constants/pivotTagCatalogSeed'); - -const COLLECTION = 'pivot_tag_catalog'; +const { seedPivotTagCatalog } = require('../services/pivotTagCatalogService'); async function run() { - const rows = getPivotTagCatalogSeedRows(); - const seedSlugs = new Set(rows.map((row) => row.slug)); - const globalDb = await connectToGlobalDatabase(); - const PivotTagCatalog = - globalDb.models.PivotTagCatalog || - globalDb.model('PivotTagCatalog', pivotTagCatalogSchema, COLLECTION); + const result = await seedPivotTagCatalog({ globalDb }); - let upserted = 0; - for (const row of rows) { - await PivotTagCatalog.findOneAndUpdate( - { slug: row.slug }, - { $set: row }, - { upsert: true, new: true, runValidators: true } - ); - upserted += 1; + if (result.error) { + throw new Error(result.error); } - const activeCount = await PivotTagCatalog.countDocuments({ active: true }); - const totalCount = await PivotTagCatalog.countDocuments({}); - const staleCount = await PivotTagCatalog.countDocuments({ - slug: { $nin: [...seedSlugs] }, - }); - + const { upserted, activeCount, totalCount, legacyNotInSeed } = result.data; console.log( - `[seed:pivot-tag-catalog] upserted=${upserted} active=${activeCount} total=${totalCount} legacy_not_in_seed=${staleCount}` + `[seed:pivot-tag-catalog] upserted=${upserted} active=${activeCount} total=${totalCount} legacy_not_in_seed=${legacyNotInSeed}`, ); console.log( - '[seed:pivot-tag-catalog] Inactive catalog slugs remain valid on legacy events but are hidden from GET /pivot/tags' + '[seed:pivot-tag-catalog] Inactive catalog slugs remain valid on legacy events but are hidden from GET /pivot/tags', ); } diff --git a/backend/routes/pivotAdminRoutes.js b/backend/routes/pivotAdminRoutes.js index 1ffa2ee2..9475b9d2 100644 --- a/backend/routes/pivotAdminRoutes.js +++ b/backend/routes/pivotAdminRoutes.js @@ -18,7 +18,7 @@ const { updateIngestEvent, } = require('../services/pivotIngestPublishService'); const { purgePivotCatalog } = require('../services/pivotCatalogPurgeService'); -const { listPivotTags } = require('../services/pivotTagCatalogService'); +const { listPivotTags, seedPivotTagCatalog } = require('../services/pivotTagCatalogService'); const { suggestPivotEventTags, suggestPivotEventTagsBatch, @@ -50,6 +50,30 @@ router.get('/tags', verifyToken, requirePlatformAdmin, async (req, res) => { } }); +router.post('/tags/seed', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const result = await seedPivotTagCatalog(req); + if (result.error) { + return res.status(result.status || 400).json({ + success: false, + message: result.error, + code: result.code, + }); + } + + return res.status(200).json({ + success: true, + data: result.data, + }); + } catch (err) { + console.error('POST /admin/pivot/tags/seed failed:', err); + return res.status(500).json({ + success: false, + message: 'Unable to seed pivot tag catalog.', + }); + } +}); + router.get('/events', verifyToken, requirePlatformAdmin, async (req, res) => { try { const result = await listPivotLabEvents(req, { diff --git a/backend/services/pivotTagCatalogService.js b/backend/services/pivotTagCatalogService.js index dfee68c6..48432a30 100644 --- a/backend/services/pivotTagCatalogService.js +++ b/backend/services/pivotTagCatalogService.js @@ -1,5 +1,6 @@ const getGlobalModels = require('./getGlobalModelService'); const { PIVOT_TAG_SLUG_PATTERN } = require('../schemas/pivotTagCatalog'); +const { getPivotTagCatalogSeedRows } = require('../constants/pivotTagCatalogSeed'); const MAX_PIVOT_INTEREST_TAGS = 8; @@ -140,8 +141,45 @@ async function listPivotTags(req, options = {}) { }; } +async function seedPivotTagCatalog(req) { + if (!req.globalDb) { + return { error: 'Global database context required.', status: 500 }; + } + + const rows = getPivotTagCatalogSeedRows(); + const seedSlugs = new Set(rows.map((row) => row.slug)); + const { PivotTagCatalog } = getGlobalModels(req, 'PivotTagCatalog'); + + let upserted = 0; + for (const row of rows) { + await PivotTagCatalog.findOneAndUpdate( + { slug: row.slug }, + { $set: row }, + { upsert: true, new: true, runValidators: true }, + ); + upserted += 1; + } + + const [activeCount, totalCount, legacyNotInSeed] = await Promise.all([ + PivotTagCatalog.countDocuments({ active: true }), + PivotTagCatalog.countDocuments({}), + PivotTagCatalog.countDocuments({ slug: { $nin: [...seedSlugs] } }), + ]); + + return { + data: { + upserted, + activeCount, + totalCount, + legacyNotInSeed, + tags: rows.map((row) => ({ slug: row.slug, label: row.label })), + }, + }; +} + module.exports = { listPivotTags, + seedPivotTagCatalog, normalizePivotTagSlugs, validatePivotEventTags, validatePivotInterestTags, diff --git a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js index 90a0026e..1bcbd15a 100644 --- a/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js +++ b/backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js @@ -53,6 +53,7 @@ jest.mock('../../services/pivotCatalogPurgeService', () => ({ jest.mock('../../services/pivotTagCatalogService', () => ({ listPivotTags: jest.fn(), + seedPivotTagCatalog: jest.fn(), })); const { requirePlatformAdmin } = require('../../middlewares/requirePlatformAdmin'); @@ -76,7 +77,7 @@ const { suggestPivotEventTagsBatch, } = require('../../services/pivotTagSuggestService'); const { purgePivotCatalog } = require('../../services/pivotCatalogPurgeService'); -const { listPivotTags } = require('../../services/pivotTagCatalogService'); +const { listPivotTags, seedPivotTagCatalog } = require('../../services/pivotTagCatalogService'); const pivotAdminRoutes = require('../../routes/pivotAdminRoutes'); function buildApp() { @@ -387,6 +388,31 @@ describe('pivotAdminRoutes GET /admin/pivot/tags', () => { }); }); +describe('pivotAdminRoutes POST /admin/pivot/tags/seed', () => { + beforeEach(() => { + seedPivotTagCatalog.mockReset(); + requirePlatformAdmin.mockImplementation((req, res, next) => next()); + }); + + it('seeds catalog tags for platform admin', async () => { + seedPivotTagCatalog.mockResolvedValue({ + data: { + upserted: 18, + activeCount: 18, + totalCount: 18, + legacyNotInSeed: 0, + tags: [{ slug: 'live-music', label: 'live music' }], + }, + }); + + const response = await request(buildApp()).post('/admin/pivot/tags/seed').send({}); + + expect(response.status).toBe(200); + expect(response.body.data.upserted).toBe(18); + expect(seedPivotTagCatalog).toHaveBeenCalled(); + }); +}); + describe('pivotAdminRoutes POST /admin/pivot/ingest/suggest-tags', () => { beforeEach(() => { suggestPivotEventTags.mockReset(); diff --git a/backend/tests/unit/pivotTagCatalogService.test.js b/backend/tests/unit/pivotTagCatalogService.test.js index 4c5582d0..a9bb1c2d 100644 --- a/backend/tests/unit/pivotTagCatalogService.test.js +++ b/backend/tests/unit/pivotTagCatalogService.test.js @@ -3,6 +3,7 @@ jest.mock('../../services/getGlobalModelService', () => jest.fn()); const getGlobalModels = require('../../services/getGlobalModelService'); const { listPivotTags, + seedPivotTagCatalog, validatePivotInterestTags, } = require('../../services/pivotTagCatalogService'); @@ -41,6 +42,24 @@ describe('pivotTagCatalogService', () => { expect(result.error).toMatch(/Global database context required/); expect(result.status).toBe(500); }); + + it('upserts seed rows and returns counts', async () => { + const findOneAndUpdate = jest.fn().mockResolvedValue({}); + const countDocuments = jest + .fn() + .mockResolvedValueOnce(18) + .mockResolvedValueOnce(18) + .mockResolvedValueOnce(0); + PivotTagCatalog = { findOneAndUpdate, countDocuments }; + getGlobalModels.mockReturnValue({ PivotTagCatalog }); + + const result = await seedPivotTagCatalog({ globalDb: {} }); + + expect(findOneAndUpdate).toHaveBeenCalled(); + expect(result.data.upserted).toBeGreaterThan(0); + expect(result.data.activeCount).toBe(18); + expect(result.data.tags.some((tag) => tag.slug === 'live-music')).toBe(true); + }); }); describe('pivotTagCatalogService validatePivotInterestTags', () => { diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx index 1f8b9b8d..9f27dcea 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx @@ -181,6 +181,7 @@ function PivotLabPage() { const [importSourceTags, setImportSourceTags] = useState([]); const [batchApplyTags, setBatchApplyTags] = useState([]); const [tagSuggestLoadingKey, setTagSuggestLoadingKey] = useState(null); + const [tagSeeding, setTagSeeding] = useState(false); const [importName, setImportName] = useState(''); const [importLocation, setImportLocation] = useState(''); const [importStartTime, setImportStartTime] = useState(''); @@ -235,12 +236,40 @@ function PivotLabPage() { const { data: tagsResponse, loading: tagsLoading, + refetch: refetchTags, } = useFetch('/admin/pivot/tags', { cache: NO_FETCH_CACHE, }); const catalogTags = tagsResponse?.success ? (tagsResponse.data?.tags ?? EMPTY_LIST) : EMPTY_LIST; + const handleSeedTagCatalog = useCallback(async () => { + setTagSeeding(true); + const { data, error } = await authenticatedRequest('/admin/pivot/tags/seed', { + method: 'POST', + data: {}, + headers: { 'Content-Type': 'application/json' }, + }); + setTagSeeding(false); + + if (error || !data?.success) { + addNotification({ + title: 'Seed failed', + message: error || data?.message || 'Unable to seed tag catalog', + type: 'error', + }); + return; + } + + const { upserted, activeCount } = data.data || {}; + addNotification({ + title: 'Tag catalog seeded', + message: `Upserted ${upserted} tags (${activeCount} active).`, + type: 'success', + }); + refetchTags(); + }, [addNotification, refetchTags]); + const buildTagSuggestPayload = useCallback((fields) => ({ name: fields.name?.trim() || undefined, description: fields.description?.trim() || undefined, @@ -971,6 +1000,22 @@ function PivotLabPage() { + {!tagsLoading && !catalogTags.length ? ( +
+

+ The global tag catalog is empty. Seed it before publishing events or using tag pickers in Lab. +

+ +
+ ) : null} + {snapshotLabel ? (

Snapshot generated {snapshotLabel} diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss index e47f231e..a2eca423 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss @@ -5,6 +5,25 @@ overflow-y: auto; box-sizing: border-box; + &__tag-seed-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0 0 16px; + padding: 12px 14px; + border: 1px solid #fde68a; + border-radius: 8px; + background: #fffbeb; + + p { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: #713f12; + } + } + &__header { display: flex; flex-wrap: wrap; diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotTagCatalogPanel/PivotTagCatalogPanel.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotTagCatalogPanel/PivotTagCatalogPanel.jsx new file mode 100644 index 00000000..6a8e73ab --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotTagCatalogPanel/PivotTagCatalogPanel.jsx @@ -0,0 +1,94 @@ +import React, { useCallback, useState } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { useFetch, authenticatedRequest } from '../../../../hooks/useFetch'; +import { useNotification } from '../../../../NotificationContext'; +import '../PivotReferralCodesPanel/PivotReferralCodesPanel.scss'; +import './PivotTagCatalogPanel.scss'; + +function PivotTagCatalogPanel() { + const { addNotification } = useNotification(); + const [seeding, setSeeding] = useState(false); + const { + data: tagsResponse, + loading, + error, + refetch, + } = useFetch('/admin/pivot/tags', { + cache: { enabled: false }, + }); + + const tags = tagsResponse?.success ? (tagsResponse.data?.tags ?? []) : []; + + const handleSeed = useCallback(async () => { + setSeeding(true); + const { data: res, error: reqError } = await authenticatedRequest('/admin/pivot/tags/seed', { + method: 'POST', + data: {}, + headers: { 'Content-Type': 'application/json' }, + }); + setSeeding(false); + + if (reqError || !res?.success) { + addNotification({ + title: 'Seed failed', + message: res?.message || reqError || 'Unable to seed tag catalog', + type: 'error', + }); + return; + } + + const { upserted, activeCount, legacyNotInSeed } = res.data || {}; + addNotification({ + title: 'Tag catalog seeded', + message: `Upserted ${upserted} tags (${activeCount} active${ + legacyNotInSeed ? `, ${legacyNotInSeed} legacy slug(s) not in seed` : '' + }).`, + type: 'success', + }); + refetch(); + }, [addNotification, refetch]); + + return ( +

+
+
+

Pivot tag catalog

+

+ Global taxonomy shared by all Pivot cities — Lab ingest, mobile interests, and feed ranker. + {loading ? ' Loading…' : ` ${tags.length} active tag${tags.length === 1 ? '' : 's'} loaded.`} +

+
+ +
+ + {error ?

{error}

: null} + + {!loading && !tags.length ? ( +

+ No tags in the catalog yet. Seed once before publishing events or assigning interests in Lab. +

+ ) : null} + + {tags.length ? ( + + ) : null} +
+ ); +} + +export default PivotTagCatalogPanel; diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotTagCatalogPanel/PivotTagCatalogPanel.scss b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotTagCatalogPanel/PivotTagCatalogPanel.scss new file mode 100644 index 00000000..218dbd6c --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotTagCatalogPanel/PivotTagCatalogPanel.scss @@ -0,0 +1,33 @@ +.pivot-tag-catalog { + &__empty { + margin: 0 0 12px; + font-size: 13px; + line-height: 1.5; + color: #6b6f76; + } + + &__list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + } + + &__chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid #e6e6e6; + border-radius: 999px; + background: #fafafa; + font-size: 12px; + } + + &__label { + color: #171717; + font-weight: 500; + } +} diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx index 8545b25e..2f6a6701 100644 --- a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx @@ -8,6 +8,7 @@ import TenantStatusDropdown from './TenantStatusDropdown/TenantStatusDropdown'; import { formatTenantHealthMessage, isTenantHealthOk } from './tenantHealthUtils'; import { isPivotTenant } from './tenantPivotUtils'; import PivotReferralCodesPanel from './PivotReferralCodesPanel/PivotReferralCodesPanel'; +import PivotTagCatalogPanel from './PivotTagCatalogPanel/PivotTagCatalogPanel'; import { useGradient } from '../../../hooks/useGradient'; import './TenantManagementPage.scss'; @@ -340,7 +341,10 @@ function TenantDetail({ {isPivotTenant(tenant) ? ( - + <> + + + ) : null} {isPivotTenant(tenant) && tenant.dropSchedule ? (