Catalog
GET /catalog returns the navigation tree the Chrome extension uses to render Layer 1–3 nav, the facet-grouped tag explorer, and the platform filter list.
Purpose
- Operators edit surfaces, groups, channels, tags, platforms in the database.
- Clients rebuild navigation from catalog data without a new build/release.
- For a Chrome-extension client this is critical: Web Store review can take days, and a hardcoded taxonomy would make every edit a release.
Contract reference: API.md §6. Data model: TAXONOMY.md.
Implementation
Assembly
CatalogService.build(bucket) assembles the response. bucket is 'public' | 'moderator' | 'admin'; hidden surfaces (those with minPermission) are filtered per bucket.
async build(bucket: PermissionBucket): Promise<Catalog> {
const [surfaces, groups, channels, platforms, tags] = await Promise.all([
this.surfaces.findActive().sort({ displayOrder: 1 }).lean(),
this.groups.findActive().sort({ displayOrder: 1 }).lean(),
this.channels.findActive().sort({ displayOrder: 1 }).lean(),
this.platforms.findActive().sort({ displayOrder: 1 }).lean(),
this.tags.findDistinctFacets(), // only facets present; or read from config
]);
const visible = surfaces.filter(s => bucketCovers(bucket, s.minPermission));
const tree = visible.map(s => ({
...pluck(s, 'slug', 'label', 'routePrefix', 'browseMode', 'accentColor', 'displayOrder'),
groups: groupsBySurface.get(s.slug)?.map(g => ({
...pluck(g, 'slug', 'label', 'color', 'displayOrder'),
channels: channelsByGroup.get(g.slug)?.map(c =>
pluck(c, 'slug', 'label', 'displayOrder'),
) ?? [],
})) ?? [],
}));
return {
surfaces: tree,
facets: tags.map(t => ({ key: t, label: FACET_LABEL[t], color: FACET_COLOR[t] })),
platforms: platforms.map(p => pluck(p, 'slug', 'label', 'color')),
updatedAt: newest([surfaces, groups, channels, platforms]).updatedAt,
};
}
Indexing in memory (groupsBySurface, channelsByGroup) avoids N×M scans. For current scale (dozens to hundreds of entries), this is negligible.
Caching
Two layers:
- Server cache — Nest
CacheModulekeyed'catalog:v1:<bucket>', TTL 300s. First request assembles; subsequent requests return cached. - HTTP cache — response sets
ETag: W/"<hash>"andCache-Control: public, max-age=300, must-revalidate. Clients sendIf-None-Match; if the ETag matches, return 304.
@Get()
@CacheKey('catalog:v1') // NestJS cache interceptor
@CacheTTL(300)
async get(@CurrentUser() user: AuthenticatedUser | null, @Req() req: Request, @Res() res: Response) {
const bucket = bucketFor(user);
const catalog = await this.catalog.build(bucket);
const etag = `W/"${sha256(JSON.stringify(catalog)).slice(0, 16)}"`;
if (req.headers['if-none-match'] === etag) return res.status(304).end();
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate');
return res.json({ success: true, data: catalog });
}
Note: the controller uses raw @Res() for ETag handling — this bypasses the global ResponseEnvelopeInterceptor. That's deliberate for the catalog endpoint. /meta does the same.
Invalidation
CatalogCacheService.invalidate() clears all buckets. Called from every taxonomy write path:
@Injectable()
export class CatalogCacheService {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async invalidate(): Promise<void> {
await Promise.all([
this.cache.del('catalog:v1:public'),
this.cache.del('catalog:v1:moderator'),
this.cache.del('catalog:v1:admin'),
]);
}
}
Wire into every write controller:
// e.g. GroupsController
@Post()
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequiredPermissions('catalog.manage')
async create(@Body() dto: CreateGroupDto): Promise<Group> {
const g = await this.groups.create(dto);
await this.catalogCache.invalidate();
return g;
}
Also invalidate on: Tag create/update/rename, Surface/Group/Channel create/update/delete (soft), Platform create/update.
Permission bucketing
Three buckets keep the cache small while honoring visibility:
public— anonymous users + logged-in users with no special permissions.moderator— hascontent.moderate.admin— hascatalog.manage.
Mapping user → bucket:
function bucketFor(user: AuthenticatedUser | null): PermissionBucket {
if (user?.permissions.includes('catalog.manage')) return 'admin';
if (user?.permissions.includes('content.moderate')) return 'moderator';
return 'public';
}
This is coarse by design. If finer visibility rules are needed later, split into more buckets — but avoid per-user caches (defeats the purpose).
Required variables and services
SurfacesService,GroupsService,ChannelsService,PlatformsService,TagsService— injected.CacheModulefrom@nestjs/cache-managerwith an in-memory store (or Redis when scaling horizontally — the cache must be shared across instances).- No env variables specific to the catalog.
Gotchas
- In-memory cache on multiple instances diverges. A write to instance A invalidates its cache but B still serves stale. Options: (a) accept up to 300s staleness, (b) use a shared Redis cache, (c) broadcast invalidations via a pub/sub. Choose (a) for v1; move to (b) when we deploy more than one replica.
- ETag over the serialized payload catches content changes but also noise (field ordering). Use a stable JSON stringifier if field order is not guaranteed.
- Empty groups. Decision in API.md §6: include all active groups regardless of whether they have content. Counts are not in the catalog.
- Catalog is hot. Every extension cold start hits it. Don't add expensive computations (aggregations, counts) without confirming they cache cleanly.
Testing
- Unit:
bucketFor— null → public, user with only submit → public, user with moderate → moderator, user with catalog.manage → admin. - Integration: seed taxonomy,
GET /catalog→ 200 with expected shape. - Integration:
GET /catalogagain withIf-None-Match: <etag from previous response>→ 304. - Integration (invalidation):
GET /catalog,POST /groups(with a test admin token),GET /catalogagain → new group appears without waiting 300s. - Integration (permission): seed a hidden surface with
minPermission: 'catalog.manage'; publicGET /catalogexcludes it; adminGET /catalogincludes it.