diff --git a/hugo-apps/src/channels-directory/ChannelsDirectory.vue b/hugo-apps/src/channels-directory/ChannelsDirectory.vue index 1e1c0f7ed..5ed895b2e 100644 --- a/hugo-apps/src/channels-directory/ChannelsDirectory.vue +++ b/hugo-apps/src/channels-directory/ChannelsDirectory.vue @@ -1,17 +1,28 @@ @@ -24,16 +35,27 @@ const results = computed(() => All categories{{ c }} + + All focus areas{{ f }} + All platforms{{ p }} + + All statuses{{ s }} + {{ results.length }} channels {{ c.name }} - Community - {{ c.purpose }} + {{ ownerBadge(c) }} + {{ c.editorialNote || c.purpose }} + + + Related {{ i + 1 }} + + diff --git a/hugo-apps/src/channels-directory/filter.test.ts b/hugo-apps/src/channels-directory/filter.test.ts index 424c30578..7a0b999ad 100644 --- a/hugo-apps/src/channels-directory/filter.test.ts +++ b/hugo-apps/src/channels-directory/filter.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { filterChannels } from './filter'; +import { filterChannels, ownerBadge } from './filter'; const data = [ - { name: 'BTP Docs', category: 'Portal', platform: 'Web', isSapOwned: true, purpose: 'docs', tags: ['btp'] }, - { name: 'Reddit SAP', category: 'Community', platform: 'Web', isSapOwned: false, purpose: 'forum', tags: ['community'] }, - { name: 'SAP YouTube', category: 'Video', platform: 'YouTube', isSapOwned: true, purpose: 'tutorials', tags: ['video'] }, + { name: 'BTP Docs', category: 'Portal', platform: 'Web', isSapOwned: true, purpose: 'docs', tags: ['btp'], focusAreas: ['btp', 'integration'], status: 'Active', ownerType: 'SAP_Official' }, + { name: 'Reddit SAP', category: 'Community', platform: 'Web', isSapOwned: false, purpose: 'forum', tags: ['community'], focusAreas: ['abap'], status: 'Active', ownerType: 'Community_Organization' }, + { name: 'SAP YouTube', category: 'Video', platform: 'YouTube', isSapOwned: true, purpose: 'tutorials', tags: ['video'], focusAreas: ['ai'], status: 'Archived', ownerType: 'SAP_Developer_Advocate' }, ]; describe('filterChannels', () => { @@ -22,6 +22,15 @@ describe('filterChannels', () => { expect(filterChannels(data, { platform: 'Web' })).toHaveLength(2); expect(filterChannels(data, { platform: 'YouTube' })).toHaveLength(1); }); + it('filters by focus area (membership, not equality)', () => { + expect(filterChannels(data, { focusArea: 'integration' }).map((c) => c.name)).toEqual(['BTP Docs']); + expect(filterChannels(data, { focusArea: 'ai' }).map((c) => c.name)).toEqual(['SAP YouTube']); + expect(filterChannels(data, { focusArea: 'nonexistent' })).toHaveLength(0); + }); + it('filters by status', () => { + expect(filterChannels(data, { status: 'Active' }).map((c) => c.name)).toEqual(['BTP Docs', 'Reddit SAP']); + expect(filterChannels(data, { status: 'Archived' }).map((c) => c.name)).toEqual(['SAP YouTube']); + }); it('applies multiple facets together (only rows matching ALL survive)', () => { // community + query: only Reddit SAP matches both expect(filterChannels(data, { query: 'forum', ownerScope: 'community' }).map((c) => c.name)).toEqual(['Reddit SAP']); @@ -29,5 +38,25 @@ describe('filterChannels', () => { expect(filterChannels(data, { category: 'Portal', ownerScope: 'sap' }).map((c) => c.name)).toEqual(['BTP Docs']); // sap + Web: BTP Docs only (SAP YouTube is sap but not Web) expect(filterChannels(data, { ownerScope: 'sap', platform: 'Web' }).map((c) => c.name)).toEqual(['BTP Docs']); + // focusArea + status: BTP Docs is btp+Active; Reddit is abap; no overlap + expect(filterChannels(data, { focusArea: 'btp', status: 'Active' }).map((c) => c.name)).toEqual(['BTP Docs']); + }); +}); + +describe('ownerBadge', () => { + it('derives distinct labels from ownerType (spec §10)', () => { + expect(ownerBadge({ name: 'x', ownerType: 'SAP_Official' })).toBe('SAP'); + expect(ownerBadge({ name: 'x', ownerType: 'SAP_Executive' })).toBe('SAP'); + expect(ownerBadge({ name: 'x', ownerType: 'SAP_Developer_Advocate' })).toBe('SAP Advocate'); + expect(ownerBadge({ name: 'x', ownerType: 'Community_Member' })).toBe('Community'); + expect(ownerBadge({ name: 'x', ownerType: 'Community_Organization' })).toBe('Community'); + expect(ownerBadge({ name: 'x', ownerType: 'User_Group' })).toBe('User Group'); + expect(ownerBadge({ name: 'x', ownerType: 'Third_party_Training' })).toBe('Third-party'); + expect(ownerBadge({ name: 'x', ownerType: 'Third_party_Media' })).toBe('Third-party'); + }); + it('falls back to isSapOwned when ownerType is absent', () => { + expect(ownerBadge({ name: 'x', isSapOwned: true })).toBe('SAP'); + expect(ownerBadge({ name: 'x', isSapOwned: false })).toBe('Community'); + expect(ownerBadge({ name: 'x' })).toBe('Community'); }); }); diff --git a/hugo-apps/src/channels-directory/filter.ts b/hugo-apps/src/channels-directory/filter.ts index 8bfb8433a..731469b95 100644 --- a/hugo-apps/src/channels-directory/filter.ts +++ b/hugo-apps/src/channels-directory/filter.ts @@ -1,9 +1,12 @@ export interface Channel { name: string; url?: string; purpose?: string; category?: string; - platform?: string; isSapOwned?: boolean; tags?: string[]; ownerType?: string; + subcategory?: string; platform?: string; isSapOwned?: boolean; + tags?: string[]; focusAreas?: string[]; relatedUrls?: string[]; + ownerType?: string; ownerName?: string; status?: string; editorialNote?: string; } export interface FilterState { query?: string; category?: string; platform?: string; + focusArea?: string; status?: string; ownerScope?: 'all' | 'sap' | 'community'; } export function filterChannels(channels: Channel[], state: FilterState): Channel[] { @@ -11,6 +14,8 @@ export function filterChannels(channels: Channel[], state: FilterState): Channel return channels.filter((c) => { if (state.category && c.category !== state.category) return false; if (state.platform && c.platform !== state.platform) return false; + if (state.status && c.status !== state.status) return false; + if (state.focusArea && !(c.focusAreas || []).includes(state.focusArea)) return false; if (state.ownerScope === 'sap' && !c.isSapOwned) return false; if (state.ownerScope === 'community' && c.isSapOwned) return false; if (q) { @@ -20,3 +25,20 @@ export function filterChannels(channels: Channel[], state: FilterState): Channel return true; }); } + +// Spec §10 labeling — owner_type-derived badge. Falls back to the coarse +// SAP/Community split when ownerType is absent (older ingest rows). +const OWNER_BADGE: Record = { + SAP_Official: 'SAP', + SAP_Developer_Advocate: 'SAP Advocate', + SAP_Executive: 'SAP', + Community_Member: 'Community', + Community_Organization: 'Community', + User_Group: 'User Group', + Third_party_Training: 'Third-party', + Third_party_Media: 'Third-party', + Third_party_Platform: 'Third-party', +}; +export function ownerBadge(c: Channel): string { + return OWNER_BADGE[c.ownerType || ''] || (c.isSapOwned ? 'SAP' : 'Community'); +} diff --git a/srv/lib/channels/promote-to-shelves.js b/srv/lib/channels/promote-to-shelves.js index f70cb7392..0076b6844 100644 --- a/srv/lib/channels/promote-to-shelves.js +++ b/srv/lib/channels/promote-to-shelves.js @@ -8,10 +8,16 @@ const CATEGORY_TO_SHELF = { 'YouTube': 'KEEP_CURRENT', 'Podcast': 'KEEP_CURRENT', 'Blog': 'KEEP_CURRENT', 'News': 'KEEP_CURRENT', 'Learning': 'START_HERE', 'Community': 'REFERENCE', }; +// Case-insensitive lookup so ingest variance ("github repository", "Docs" +// vs "docs") still maps deterministically instead of silently defaulting. +const CATEGORY_TO_SHELF_LC = Object.fromEntries( + Object.entries(CATEGORY_TO_SHELF).map(([k, v]) => [k.toLowerCase(), v]), +); const FOCUS_TO_VERB = [ [['integration'], 'INTEGRATE'], [['ops', 'admin', 'operations'], 'OPERATE'], [['ai', 'genai'], 'AI'], [['rap', 'data-model', 'cds'], 'MODEL'], [['abap', 'cap', 'sdk', 'build'], 'BUILD'], [['onboarding', 'tutorial', 'learn'], 'LEARN'], + [['community', 'network', 'networking', 'events', 'connect'], 'CONNECT'], ]; function pickVerb(focusAreas = []) { @@ -21,7 +27,7 @@ function pickVerb(focusAreas = []) { } function mapChannelToShelf(channel) { - let shelf = CATEGORY_TO_SHELF[channel.category] || 'REFERENCE'; + let shelf = CATEGORY_TO_SHELF_LC[String(channel.category || '').toLowerCase()] || 'REFERENCE'; // community / third-party may never land in START_HERE if (shelf === 'START_HERE' && channel.isSapOwned !== true) shelf = 'REFERENCE'; return { verb: pickVerb(channel.focusAreas), shelf }; diff --git a/srv/server.js b/srv/server.js index fbb91bc95..3bafa78df 100644 --- a/srv/server.js +++ b/srv/server.js @@ -430,14 +430,27 @@ cds.on('bootstrap', (app) => { .orderBy('category', 'name'), ); const parseArr = (v) => (Array.isArray(v) ? v : (typeof v === 'string' && v ? JSON.parse(v) : [])); + // Explicit public projection — never spread the full row into an + // anon feed (drops managed audit + internal curation columns: + // sourceId, notes, aliases, contentHash, ingestBatch, lastChecked, + // isFeatured, linkStatusOverride, createdBy/modifiedBy, …). const channels = rows .map((r) => ({ - ...r, + name: r.name, + url: r.url, + purpose: r.purpose, + category: r.category, + subcategory: r.subcategory, + platform: r.platform, + isSapOwned: r.isSapOwned, + ownerType: r.ownerType, + ownerName: r.ownerName, + status: r.status, + editorialNote: r.editorialNote, linkStatus: r.linkStatusOverride || r.linkStatus, focusAreas: parseArr(r.focusAreas), tags: parseArr(r.tags), relatedUrls: parseArr(r.relatedUrls), - aliases: parseArr(r.aliases), })) .filter((r) => r.linkStatus !== 'BROKEN'); res.set('Cache-Control', 'public, max-age=60'); diff --git a/test/build-channels-feed.test.js b/test/build-channels-feed.test.js index 08245c456..aec51f674 100644 --- a/test/build-channels-feed.test.js +++ b/test/build-channels-feed.test.js @@ -22,12 +22,24 @@ describe('GET /build/channels', () => { it('returns only published, non-broken channels with parsed arrays', async () => { const { status, data } = await project.get('/build/channels'); expect(status).toBe(200); - const ids = data.channels.map((c) => c.sourceId); - expect(ids).toContain('feed-pub'); - expect(ids).not.toContain('feed-unpub'); - expect(ids).not.toContain('feed-broken'); - const pub = data.channels.find((c) => c.sourceId === 'feed-pub'); + const urls = data.channels.map((c) => c.url); + expect(urls).toContain('https://pub'); + expect(urls).not.toContain('https://unpub'); + expect(urls).not.toContain('https://broken'); + const pub = data.channels.find((c) => c.url === 'https://pub'); expect(pub.focusAreas).toEqual(['btp']); expect(typeof data.buildAt).toBe('string'); }); + + it('projects a public whitelist — no audit / internal curation columns', async () => { + const { data } = await project.get('/build/channels'); + const pub = data.channels.find((c) => c.url === 'https://pub'); + for (const internal of ['sourceId', 'contentHash', 'ingestBatch', 'linkStatusOverride', 'isFeatured', 'notes', 'aliases', 'createdBy', 'modifiedBy', 'createdAt', 'modifiedAt']) { + expect(pub, `feed leaked internal column "${internal}"`).not.toHaveProperty(internal); + } + // consumed public fields are present + for (const pubfield of ['name', 'url', 'category', 'status', 'ownerType', 'focusAreas']) { + expect(pub).toHaveProperty(pubfield); + } + }); }); diff --git a/test/channels-promote.test.js b/test/channels-promote.test.js index 4c86edf75..ab4c00325 100644 --- a/test/channels-promote.test.js +++ b/test/channels-promote.test.js @@ -14,11 +14,19 @@ describe('mapChannelToShelf', () => { }); it('never puts a community channel in START_HERE', () => { const m = mapChannelToShelf({ isSapOwned: false, category: 'Learning', focusAreas: ['onboarding'] }); - expect(m?.shelf).not.toBe('START_HERE'); + expect(m.shelf).not.toBe('START_HERE'); }); it('maps a GitHub repo to TOOLS', () => { expect(mapChannelToShelf({ isSapOwned: true, category: 'GitHub Repository', focusAreas: ['cap'] }).shelf).toBe('TOOLS'); }); + it('matches category case-insensitively', () => { + expect(mapChannelToShelf({ isSapOwned: true, category: 'github repository', focusAreas: ['cap'] }).shelf).toBe('TOOLS'); + expect(mapChannelToShelf({ isSapOwned: true, category: 'DOCS', focusAreas: ['cap'] }).shelf).toBe('REFERENCE'); + }); + it('reaches the CONNECT verb for community/networking focus areas', () => { + expect(mapChannelToShelf({ isSapOwned: true, category: 'Community', focusAreas: ['community'] }).verb).toBe('CONNECT'); + expect(mapChannelToShelf({ isSapOwned: true, category: 'Portal', focusAreas: ['networking'] }).verb).toBe('CONNECT'); + }); }); describe('promoteFeatured', () => {
{{ c.purpose }}
{{ c.editorialNote || c.purpose }}