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: setsapprovalStatus='approved',approvedAt=now,approvalMeta={actorUserId, actorAt}.pending → rejected: setsapprovalStatus='rejected', optionalapprovalMeta.reason.rejected → pending(revive): moderator-only. ClearsapprovalMeta.approved → rejectedorapproved → 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=falseis orthogonal: apply to any status. List endpoints filterisActive=trueby 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:
- Thumbnail mip generation. See thumbnails.md.
- Tag usage count increment. Each slug in
content.tagSlugsgets a+1ontags.usageCount(single$incupdate with a$infilter). Optional v1; correctness without it is fine because the tag explorer's by-usage sort is a UX polish, not a correctness feature. - 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.approvedAttruthiness before decrementing.
Every listener catches its own errors — see events.md.
Required variables and services
ContentModelEventEmitter2- 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").
approvedAtdrives 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,
approvedAtset, 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,
approvalMetacleared. - Integration: moderator approves;
GET /content?status=approvednow includes it;GET /content?status=pendingexcludes it. - Integration: submitter without
content.approvetries to approve their own submission → 403auth.forbidden. - Integration: approve emits
content.approvedand the thumbnails listener fires (mocked). - Integration: concurrent approves — one wins, other is idempotent, count of approval events is 1.