Building a Content Generation Pipeline: When User Intent Meets Background Jobs
The hardest part of building AI-powered features isn’t the AI itself—it’s everything that happens before and after the AI does its work.
I just added a new endpoint that lets users generate social media content from their code commits. On the surface, it’s straightforward: user clicks “generate,” AI writes posts, done. But between that click and the result sits a minefield of edge cases.
The Problem: User Actions Need Guardrails
When you expose any resource-intensive operation to users, you’re not just building a feature—you’re building a contract. Users will click that button when they shouldn’t, when they can’t, or when they already have. Your code needs to say “no” gracefully to all of them.
Here’s what I needed to prevent:
- Users generating content for commits that aren’t worth posting about
- Users hitting “generate” multiple times while the first job is still running
- Users on free plans trying to access paid features
- Users who’ve hit their daily limit trying to squeeze in one more
- Users accessing commits they don’t own
Each of these isn’t just a validation check—it’s a distinct business rule with its own error message.
The Validation Cascade
The solution I landed on is a series of progressive checks, each with a specific purpose:
// 1. Does this resource exist?
const commit = await this.commitRepository.findById(commitId);
if (!commit) {
throw new NotFoundException('Commit not found');
}
// 2. Does this user own it?
const repository = await this.repositoryRepository.findById(commit.repositoryId);
if (!repository || repository.userId !== userId) {
throw new ForbiddenException('Not authorized to access this commit');
}
// 3. Is it eligible?
if (!commit.isWorthy) {
throw new BadRequestException('Cannot generate content for a commit that is not worthy');
}
// 4. Has this already been done?
if (commit.processingStatus === CommitProcessingStatus.COMPLETED) {
throw new BadRequestException('Content has already been generated for this commit');
}The order matters. You check existence before ownership (no point leaking whether a resource exists to unauthorized users). You check ownership before business rules (don’t leak business logic about resources they can’t access).
Subscription Limits as First-Class Citizens
The trickiest part was handling subscription-based access. Different plans allow different platforms and different daily limits. This isn’t just a boolean check—it’s data that shapes what the user can do.
const subscriptionCheck = await this.subscriptionLimitService.checkSubscription(userId);
if (!subscriptionCheck.hasActiveSubscription) {
throw new ForbiddenException('Active subscription required to generate content');
}
const worthyCheck = await this.subscriptionLimitService.canGenerateWorthy(userId);
if (!worthyCheck.allowed) {
throw new ForbiddenException(worthyCheck.reason || 'Daily content generation limit reached');
}The subscription service returns structured data about what platforms are available. I map those to the internal platform enum because the subscription system uses user-facing terminology while the content generation system uses internal constants:
const allowedPlatforms = subscriptionCheck.limits?.platforms ?? [];
const platformsToGenerate = allowedPlatforms
.map((p) => this.socialPlatformToEnum(p))
.filter((p): p is Platform => p !== null);That filter with a type predicate is doing double duty—removing nulls and telling TypeScript that what remains is definitely Platform[], not (Platform | null)[].
The State Machine Pattern
One decision I’m happy with: treating commit processing as a state machine. A commit can be PENDING, PROCESSING, or COMPLETED. This makes idempotency natural:
if (commit.processingStatus === CommitProcessingStatus.PROCESSING) {
throw new BadRequestException('Content generation is already in progress');
}
// Update commit status to pending
await this.commitRepository.updateStatus(commit.id, CommitProcessingStatus.PENDING);
// Queue the job
const jobId = await this.queueService.addContentGenerationJob({
commitId: commit.id,
userId,
platforms: platformsToGenerate,
});By marking it as PENDING before queuing the job, I create a window where duplicate requests will be rejected. If the job fails, a separate error handler will reset the status. If it succeeds, the worker will mark it COMPLETED.
Observability Through Activity Logs
Every significant user action gets logged to an activity stream. This isn’t just for debugging—it’s a feature. Users can see their history of what they’ve generated:
await this.logActivity.execute({
userId,
type: ActivityType.COMMIT_PROCESSED,
description: Requested content generation for commit ${commit.sha.substring(0, 7)},
resourceType: ResourceType.COMMIT,
resourceId: commit.id,
metadata: {
commitSha: commit.sha.substring(0, 7),
repositoryName: repository.fullName,
platforms: platformsToGenerate,
jobId,
},
isAutomated: false,
});The jobId in the metadata is crucial. If something goes wrong, I can trace from the user’s activity log to the exact background job that failed.
What I’d Do Differently
Looking at this now, there’s a subtle race condition. Between checking the status and updating it to PENDING, another request could slip through. The fix is a database-level constraint or an atomic compare-and-swap operation:
// Better: atomic status update that fails if status changed
const updated = await this.commitRepository.updateStatusIfMatches(
commit.id,
CommitProcessingStatus.NOT_GENERATED,
CommitProcessingStatus.PENDING
);
if (!updated) {
throw new BadRequestException('Content generation is already in progress');
}I’ll add that in a future iteration when I see whether it actually becomes a problem in practice.
The Takeaway
Building user-facing features that trigger expensive background work requires thinking through every edge case before users find them. The code ends up being mostly validation and state management, with the actual “feature” (queueing a job) being just one line.
But that’s what makes it production-ready. The AI generation is the easy part. Making sure it only runs when it should, for who it should, with proper limits and logging—that’s the real work.

Aniefon Umanah
Full Stack Engineer with 7+ years of experience. I write about software development, architecture patterns, and lessons learned from building real-world applications.