Skip to main content

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 }
  • alpha sort: { 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 surface user for 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-slug 404s for anonymous callers, not 403. Disclosing existence is a leak.
  • $text requires the text index. If indexes are missing (e.g. fresh dev DB without running model.syncIndexes()), text search returns empty or errors. Seed script should call syncIndexes() after upserting content-related rows.
  • Tag OR semantics with a single tag still uses $in: [slug] — one-element $in and 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 @Transform or 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. Cap q to 200 chars (DTO) and consider skipping count when q is present (total: -1 convention) if profiling shows it's a bottleneck.

Testing

  • Unit: buildFilter cases — public default, public with status=pending (should throw), moderator with status=pending, tag OR, platform OR, period windowing, text search.
  • Unit: buildSort — with and without q; 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=a returns only tagged items; ?tags=a,b returns items tagged with either.
  • Integration: ?q=splat returns items matching title or description.
  • Integration: ?sort=alpha orders alphabetically.
  • Integration: ?period=7d excludes items approved > 7 days ago.
  • Integration: ?offset=45 limit=10 on 50 items → 5 items, hasMore=false.