Content submit
POST /content/submit — authenticated user submits a URL. Content enters the system as pending and waits for moderator approval.
Purpose
Any authenticated user can propose content. The system captures enough to let a moderator decide quickly (URL, title, platform, optional description and tags). No scraping, no auto-approval, no federation — the submitter is asserting the content exists; the moderator decides if it belongs.
Contract
POST /content/submit
Authorization: Bearer <access-token>
Content-Type: application/json
{
"url": "https://youtube.com/watch?v=abc",
"title": "Intro to Gaussian Splatting",
"description": "Optional — <= 2000 chars",
"platformSlug": "youtube", // optional; inferred from URL if omitted
"groupSlug": "ai", // optional; defaults to CONTENT_DEFAULT_GROUP_SLUG
"channelSlug": "video", // optional
"tagSlugs": ["gaussian-splats", "3d"] // optional; must exist and be active
}
→ 201 { success: true, data: ContentDto } with approvalStatus: "pending"
Errors:
400 validation.failed— URL missing/invalid, title missing/too long, tag slug format invalid.400 tag.unknown— one or moretagSlugsdon't match an active tag (details includes the list).400 group.unknown/channel.unknown— taxonomy slug doesn't exist or is inactive.409 content.duplicate— this user has already submitted this URL and it's still pending or approved.
Implementation
DTO
export class SubmitContentDto {
@IsUrl({ require_protocol: true }) @MaxLength(2048)
url!: string;
@IsString() @MinLength(1) @MaxLength(200)
title!: string;
@IsOptional() @IsString() @MaxLength(2000)
description?: string;
@IsOptional() @IsSlug()
platformSlug?: string;
@IsOptional() @IsSlug()
groupSlug?: string;
@IsOptional() @IsSlug()
channelSlug?: string;
@IsOptional() @IsArray() @ArrayMaxSize(20) @IsSlug({ each: true })
tagSlugs?: string[];
}
Platform inference
// src/content/platform-inference.ts
const HOSTNAME_MAP: [RegExp, string][] = [
[/(^|\.)youtube\.com$/, 'youtube'],
[/(^|\.)youtu\.be$/, 'youtube'],
[/(^|\.)(twitter|x)\.com$/, 'twitter'],
[/(^|\.)bsky\.app$/, 'bluesky'],
[/(^|\.)reddit\.com$/, 'reddit'],
];
export function inferPlatform(url: string): string {
try {
const host = new URL(url).hostname.toLowerCase();
for (const [re, slug] of HOSTNAME_MAP) if (re.test(host)) return slug;
return 'generic';
} catch {
return 'generic';
}
}
The result is validated against the active platforms collection — if inferPlatform returns a slug that's been deactivated (or was never seeded), fall back to generic.
Service flow
async submit(dto: SubmitContentDto, userId: string): Promise<Content> {
// 1. Resolve and validate taxonomy
const platformSlug = dto.platformSlug ?? inferPlatform(dto.url);
await this.platforms.assertActive(platformSlug);
const groupSlug = dto.groupSlug ?? this.config.defaultGroupSlug;
await this.groups.assertActive(groupSlug);
if (dto.channelSlug) await this.channels.assertActive(dto.channelSlug, groupSlug);
if (dto.tagSlugs?.length) await this.tags.assertAllActive(dto.tagSlugs);
// 2. Duplicate detection
const existing = await this.model.findOne({
url: dto.url,
submittedBy: userId,
approvalStatus: { $in: ['pending', 'approved'] },
isActive: true,
}).lean();
if (existing) throw new ConflictError('content.duplicate', { slug: existing.slug });
// 3. Slug generation with collision handling
const slug = await this.generateSlug(dto.title);
// 4. Insert
const doc = await this.model.create({
slug,
title: dto.title,
description: dto.description,
url: dto.url,
platformSlug,
groupSlug,
channelSlug: dto.channelSlug,
tagSlugs: dto.tagSlugs ?? [],
submittedBy: userId,
approvalStatus: 'pending',
surfaceSlug: 'content', // single-surface v1
isActive: true,
});
// 5. Emit
this.events.emit('content.submitted', { contentId: doc._id.toString(), submittedBy: userId });
return doc;
}
Slug generation
private async generateSlug(title: string): Promise<string> {
const base = slugify(title) || 'item';
// First try unsuffixed
if (!(await this.model.exists({ slug: base }))) return base;
// Then append a 6-char random suffix. One retry; collisions at this entropy are astronomical.
for (let i = 0; i < 3; i++) {
const candidate = `${base}-${randomBase36(6)}`;
if (!(await this.model.exists({ slug: candidate }))) return candidate;
}
throw new Error('slug generation failed after 3 attempts'); // should never happen
}
Why random and not counter? A counter-based approach (-2, -3) races under concurrent submits with the same title. Random collides at roughly 1 / 36^6 ≈ 5×10⁻¹⁰ — good enough.
Duplicate detection
Same url + submittedBy + alive (pending or approved, isActive=true) → reject as duplicate. Different submitters of the same URL are allowed through — moderators may wish to see curatorial overlap, and rejecting would cap the feedback signal.
An index supports this check: { url: 1, submittedBy: 1 }.
Required variables and services
| Env | Purpose |
|---|---|
CONTENT_DEFAULT_GROUP_SLUG | Fallback when groupSlug is omitted (e.g. general) |
Services:
TagsService.assertAllActive(slugs)GroupsService.assertActive(slug),ChannelsService.assertActive(slug, groupSlug),PlatformsService.assertActive(slug)EventEmitter2— emitscontent.submitted
Gotchas
- URL normalization. The submitted
https://youtube.com/watch?v=abcandhttps://www.youtube.com/watch?v=abc&feature=shareare "the same content" to a human but different strings. v1 does not normalize — duplicate detection is exact-match. Consider URL canonicalization (strip utm_*, lowercase host, dropwww.) if duplicate approvals become a moderation headache. - Submitted titles can be hostile. An attacker submits title
<script>alert(1)</script>. Title is stored verbatim; escaping is the client's responsibility on render. The API does not sanitize. - Tag auto-creation is forbidden. Accepting unknown tags would let any submitter pollute the vocabulary. If the submitter really wants a new tag, they ask a moderator. Enforced:
assertAllActiverejects unknowns. - Rate limit is per user. 30/hr/user is the starting cap. Enforced via
@Throttle({ default: { limit: 30, ttl: 3600 } })on the controller method. surfaceSlug: 'content'is hardcoded in v1. When a second surface exists, derive from a field on the DTO or from the default configured for the default group.
Testing
- Unit:
inferPlatform— YouTube, Twitter, Bluesky, Reddit, unknown, malformed URL. - Unit:
generateSlugwith a mocked model that always returnsexists=trueforces the suffixed path; sanity-check fallback. - Unit:
slugify— emoji title produces non-empty fallback via'item'. - Integration: submit with valid data → 201, pending in DB, event emitted.
- Integration: submit missing
title→ 400 validation.failed. - Integration: submit with unknown tag slug → 400 tag.unknown.
- Integration: submit same URL twice as same user → 409 content.duplicate.
- Integration: submit same URL as two different users → both succeed.
- Integration: submit without
platformSlugon a YouTube URL → platform resolves toyoutube. - Integration: submit 31 times in an hour → 31st returns 429.