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. +
+ +
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 (
+
+ 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}
+ No tags in the catalog yet. Seed once before publishing events or assigning interests in Lab.
+ Pivot tag catalog
+
+ {tags.map((tag) => (
+
+ ) : null}
+ {tag.slug}
+