Content search
One endpoint (GET /content) handles every list, filter, and text-search use case. The default feed, a group view, a tag-filtered query, and a text search all go through the same code path.
Purpose
Multiple endpoints would duplicate logic and invite drift (e.g. group view supports sort X but text search doesn't). One endpoint enforces uniformity; the client composes filters however it likes.
Contract reference: API.md §5.
Implementation
DTO
export class ContentSearchQueryDto extends PaginationQueryDto {
@IsOptional() @IsString() @MinLength(2) @MaxLength(200)
q?: string;
@IsOptional() @Transform(({ value }) => value.split(',').map((s: string) => s.trim())) @IsArray() @IsSlug({ each: true })
tags?: string[];
@IsOptional() @IsSlug() group?: string;
@IsOptional() @IsSlug() channel?: string;
@IsOptional() @IsSlug() platform?: string;
@IsOptional() @IsIn(['24h', '7d', '30d', '90d', 'all'])
period: '24h' | '7d' | '30d' | '90d' | 'all' = 'all';
@IsOptional() @IsIn(['newest', 'oldest', 'alpha'])
sort: 'newest' | 'oldest' | 'alpha' = 'newest';
@IsOptional() @IsIn(['approved', 'pending', 'rejected', 'all'])
status?: 'approved' | 'pending' | 'rejected' | 'all';
}
Filter builder
function buildFilter(q: ContentSearchQueryDto, user: AuthenticatedUser | null): FilterQuery<Content> {
const filter: FilterQuery<Content> = { isActive: true };
// status (default 'approved' for public; only moderators can ask for more)
const requestedStatus = q.status ?? 'approved';
if (requestedStatus !== 'approved' && !user?.permissions.includes('content.moderate')) {
throw new ForbiddenError('auth.forbidden', { missing: ['content.moderate'] });
}
if (requestedStatus !== 'all') filter.approvalStatus = requestedStatus;
// taxonomy (AND)
if (q.group) filter.groupSlug = q.group;
if (q.channel) filter.channelSlug = q.channel;
if (q.platform) {
const platforms = q.platform.split(',');
filter.platformSlug = platforms.length === 1 ? platforms[0] : { $in: platforms };
}
// tags (OR within)
if (q.tags?.length) filter.tagSlugs = { $in: q.tags };
// period (over approvedAt for approved, createdAt for everything else)
if (q.period !== 'all') {
const since = new Date(Date.now() - PERIOD_MS[q.period]);
const field = requestedStatus === 'approved' ? 'approvedAt' : 'createdAt';
filter[field] = { $gte: since };
}
// text search (uses Mongo text index)
if (q.q) filter.$text = { $search: q.q };
return filter;
}
const PERIOD_MS: Record<string, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000,
};
Sort
const SORT_MAP: Record<string, Record<string, 1 | -1>> = {
newest: { approvedAt: -1 },
oldest: { approvedAt: 1 },
alpha: { title: 1 },
};
function buildSort(q: ContentSearchQueryDto): Record<string, 1 | -1> {
const base = q.q ? { score: { $meta: 'textScore' } } : SORT_MAP[q.sort];
return { ...base, _id: -1 };
}
Text search uses Mongo's $meta: textScore to rank relevance; without q, fall back to the requested sort.
Search method
async search(q: ContentSearchQueryDto, user: AuthenticatedUser | null): Promise<Paginated<ContentDto>> {
const filter = buildFilter(q, user);
const sort = buildSort(q);
const cursor = this.model.find(filter);
if (q.q) cursor.select({ score: { $meta: 'textScore' } });
const [items, total] = await Promise.all([
cursor.sort(sort).skip(q.offset).limit(q.limit).lean(),
this.model.countDocuments(filter),
]);
return paginated(items.map(toContentDto), total, q);
}
Indexes that support the hot paths
See TAXONOMY.md §7 — summary:
- Default feed (no filters):
{ approvalStatus: 1, isActive: 1, approvedAt: -1, _id: -1 } - Tag filter: multikey on
tagSlugs, intersected with default-feed index - Group filter:
{ groupSlug: 1, approvedAt: -1 } - Channel filter:
{ groupSlug: 1, channelSlug: 1, approvedAt: -1 } - Platform filter:
{ platformSlug: 1, approvedAt: -1 } - Text search: text index on
{ title, description } alphasort:{ title: 1 }
Mongo's query planner picks the best; compound scenarios rely on intersection. Run .explain('executionStats') on the top query shapes during development to catch regressions.
Required variables and services
- None directly. Uses
ContentModel,PaginationQueryDto,OptionalJwtAuthGuard(to surfaceuserfor status gating).
Controller wiring:
@Get()
@UseGuards(OptionalJwtAuthGuard)
search(
@Query() q: ContentSearchQueryDto,
@CurrentUser() user: AuthenticatedUser | null,
): Promise<Paginated<ContentDto>> {
return this.content.search(q, user);
}
Gotchas
- Public clients cannot ever see non-approved content. Even a direct-request
GET /content/pending-slug404s for anonymous callers, not 403. Disclosing existence is a leak. $textrequires the text index. If indexes are missing (e.g. fresh dev DB without runningmodel.syncIndexes()), text search returns empty or errors. Seed script should callsyncIndexes()after upserting content-related rows.- Tag OR semantics with a single tag still uses
$in: [slug]— one-element$inand equality are equivalent to Mongo; don't special-case. - Multi-platform filter accepts a comma-separated list; document this in API.md consistently.
- Empty
q(?q=) is treated as absent. The DTO's@MinLength(2)only fires when the field is present — combined with@IsOptional()an empty string still fails validation. Either strip empties in a@Transformor let the 400 happen — our call: let the 400 happen, it's a client bug. - Counts with text search are costly:
countDocuments({ $text: { $search: ... } })scans. Capqto 200 chars (DTO) and consider skipping count whenqis present (total: -1convention) if profiling shows it's a bottleneck.
Testing
- Unit:
buildFiltercases — public default, public with status=pending (should throw), moderator with status=pending, tag OR, platform OR, period windowing, text search. - Unit:
buildSort— with and withoutq; always includes_id. - Integration: seed 50 approved + 10 pending;
GET /content→ 30 approved items,total: 50. - Integration: moderator with
?status=pending→ 10 items. - Integration:
?tags=areturns only tagged items;?tags=a,breturns items tagged with either. - Integration:
?q=splatreturns items matching title or description. - Integration:
?sort=alphaorders alphabetically. - Integration:
?period=7dexcludes items approved > 7 days ago. - Integration:
?offset=45 limit=10on 50 items → 5 items, hasMore=false.