Skip to main content

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 more tagSlugs don'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

EnvPurpose
CONTENT_DEFAULT_GROUP_SLUGFallback when groupSlug is omitted (e.g. general)

Services:

  • TagsService.assertAllActive(slugs)
  • GroupsService.assertActive(slug), ChannelsService.assertActive(slug, groupSlug), PlatformsService.assertActive(slug)
  • EventEmitter2 — emits content.submitted

Gotchas

  • URL normalization. The submitted https://youtube.com/watch?v=abc and https://www.youtube.com/watch?v=abc&feature=share are "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, drop www.) 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: assertAllActive rejects 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: generateSlug with a mocked model that always returns exists=true forces 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 platformSlug on a YouTube URL → platform resolves to youtube.
  • Integration: submit 31 times in an hour → 31st returns 429.