Skip to main content

Content approval

Moderators transition submitted content between pending, approved, and rejected states. Approval is one action; downstream side effects are fire-and-forget.


Purpose

Content from user submissions defaults to pending and is invisible to public clients until a moderator approves. Approval is fast (one click), idempotent (a double-click returns 200), and decoupled from heavy work (thumbnails, counts) via events.


State machine

submit


┌────────┐ approve ┌──────────┐
│pending │────────────▶│ approved │
│ │ │ │
│ │ reject └─────┬────┘
│ │────────┐ │ softDelete
│ │ │ ▼
└────────┘ │ (isActive: false;
▲ │ status unchanged)
reviveToPending │
│ ▼
┌──────────┐
│ rejected │
└──────────┘

Rules:

  • pending → approved: sets approvalStatus='approved', approvedAt=now, approvalMeta={actorUserId, actorAt}.
  • pending → rejected: sets approvalStatus='rejected', optional approvalMeta.reason.
  • rejected → pending (revive): moderator-only. Clears approvalMeta.
  • approved → rejected or approved → pending: not permitted via the API. Soft-delete first (isActive=false), then handle manually if needed. Avoiding this transition keeps the approval audit clean.
  • isActive=false is orthogonal: apply to any status. List endpoints filter isActive=true by default.

Illegal transitions return 422 content.state_invalid with details.from and details.to.


Contract

POST /content/<slug>/approve
POST /content/<slug>/reject { reason?: string }
POST /content/<slug>/revive (rejected → pending)
POST /content/<slug>/deactivate (soft-delete; isActive=false)

All require content.approve (approve/reject) or content.delete (deactivate) permission. revive requires content.approve.

Successful response returns the updated ContentDto in the envelope.


Implementation

approve

async approve(slug: string, actorId: string): Promise<Content> {
const content = await this.model.findOne({ slug, isActive: true });
if (!content) throw new NotFoundError('content.not_found', { slug });

if (content.approvalStatus === 'approved') {
// Idempotent: second approval is a no-op.
return content.toObject();
}
if (content.approvalStatus !== 'pending') {
throw new UnprocessableError('content.state_invalid', {
slug, from: content.approvalStatus, to: 'approved',
});
}

content.approvalStatus = 'approved';
content.approvedAt = new Date();
content.approvalMeta = { actorUserId: new Types.ObjectId(actorId), actorAt: new Date() };
await content.save();

this.events.emit('content.approved', { contentId: content._id.toString(), actorId });

return content.toObject();
}

reject

async reject(slug: string, actorId: string, reason?: string): Promise<Content> {
const content = await this.model.findOne({ slug, isActive: true });
if (!content) throw new NotFoundError('content.not_found', { slug });

if (content.approvalStatus === 'rejected') return content.toObject();
if (content.approvalStatus !== 'pending') {
throw new UnprocessableError('content.state_invalid', {
slug, from: content.approvalStatus, to: 'rejected',
});
}

content.approvalStatus = 'rejected';
content.approvalMeta = {
actorUserId: new Types.ObjectId(actorId), actorAt: new Date(), reason,
};
await content.save();

this.events.emit('content.rejected', { contentId: content._id.toString(), actorId, reason });

return content.toObject();
}

revive and deactivate

Mirror the pattern above. revive clears approvalMeta; deactivate sets isActive=false and emits content.deactivated.

Idempotency contract

"Already in the target state" returns 200 with the current document. The client may optionally use meta.unchanged: true in the envelope by annotating the method — not required in v1, but reserved so moderator UIs can show a subtle "already approved" hint instead of animating a state change that didn't happen.


Downstream events

On content.approved:

  1. Thumbnail mip generation. See thumbnails.md.
  2. Tag usage count increment. Each slug in content.tagSlugs gets a +1 on tags.usageCount (single $inc update with a $in filter). Optional v1; correctness without it is fine because the tag explorer's by-usage sort is a UX polish, not a correctness feature.
  3. Notification fan-out. Future. Out of scope for v1.

On content.rejected or content.deactivated:

  • Tag usage count decrement if the content was previously approved. Check content.approvedAt truthiness before decrementing.

Every listener catches its own errors — see events.md.


Required variables and services

  • ContentModel
  • EventEmitter2
  • Guards: JwtAuthGuard + PermissionsGuard
  • Permissions: content.approve, content.delete

Gotchas

  • Idempotency distinguishes from "already-different-terminal-state". Approving a rejected item is a 422, not a 200 — the client needs to revive first. Being strict here keeps the audit trail intact ("a moderator explicitly moved this back to pending at time T").
  • approvedAt drives the default feed sort. Leave it set even on soft-delete, so revival doesn't require reconstruction.
  • Tag usage count drift. Increment/decrement is eventually consistent. The reconciliation job (see TAXONOMY.md §6) recomputes by counting documents. Run nightly once traffic exists.
  • Transactions are not required for approve — it's a single document write. Only tag rename needs a transaction.
  • Race: two moderators approve simultaneously. First write wins; second call loads the now-approved doc, returns idempotent 200. No corruption — just one of them "approved" nothing.

Testing

  • Unit: approve on pending → approved, approvedAt set, event emitted.
  • Unit: approve on approved → returns current state, no event.
  • Unit: approve on rejected → throws content.state_invalid.
  • Unit: reject on pending → rejected, event emitted.
  • Unit: revive on rejected → pending, approvalMeta cleared.
  • Integration: moderator approves; GET /content?status=approved now includes it; GET /content?status=pending excludes it.
  • Integration: submitter without content.approve tries to approve their own submission → 403 auth.forbidden.
  • Integration: approve emits content.approved and the thumbnails listener fires (mocked).
  • Integration: concurrent approves — one wins, other is idempotent, count of approval events is 1.