Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 7 additions & 26 deletions backend/migrations/seedPivotTagCatalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}

Expand Down
26 changes: 25 additions & 1 deletion backend/routes/pivotAdminRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
38 changes: 38 additions & 0 deletions backend/services/pivotTagCatalogService.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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,
Expand Down
28 changes: 27 additions & 1 deletion backend/tests/route-outcomes/pivotAdminRoutes.outcomes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jest.mock('../../services/pivotCatalogPurgeService', () => ({

jest.mock('../../services/pivotTagCatalogService', () => ({
listPivotTags: jest.fn(),
seedPivotTagCatalog: jest.fn(),
}));

const { requirePlatformAdmin } = require('../../middlewares/requirePlatformAdmin');
Expand All @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
19 changes: 19 additions & 0 deletions backend/tests/unit/pivotTagCatalogService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ jest.mock('../../services/getGlobalModelService', () => jest.fn());
const getGlobalModels = require('../../services/getGlobalModelService');
const {
listPivotTags,
seedPivotTagCatalog,
validatePivotInterestTags,
} = require('../../services/pivotTagCatalogService');

Expand Down Expand Up @@ -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', () => {
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -971,6 +1000,22 @@ function PivotLabPage() {
</div>
</header>

{!tagsLoading && !catalogTags.length ? (
<div className="pivot-lab__tag-seed-banner" role="status">
<p>
The global tag catalog is empty. Seed it before publishing events or using tag pickers in Lab.
</p>
<button
type="button"
className="linear-btn linear-btn--primary linear-btn--sm"
onClick={handleSeedTagCatalog}
disabled={tagSeeding}
>
{tagSeeding ? 'Seeding…' : 'Seed tag catalog'}
</button>
</div>
) : null}

{snapshotLabel ? (
<p className="pivot-lab__snapshot-meta">
Snapshot generated {snapshotLabel}
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading