Blog

  • Building a Job Classification System That Actually Understands What Companies Are Hiring For

    When you’re building an automated job application system, the temptation is to treat all jobs the same. Parse the description, match some keywords, maybe calculate a score. But here’s the problem: a “Senior Software Engineer” role at a 10-person startup means something fundamentally different than the same title at a Fortune 500 company.

    The startup wants someone who can ship features fast and tolerate ambiguity. The enterprise wants someone who can navigate compliance requirements and write documentation. If your resume emphasizes the wrong signals, you’re getting auto-rejected before a human even looks at it.

    The Real Problem: Resume Tailoring Is Actually Hard

    Most job application tools treat resume customization as a cosmetic problem. Change a few words here, swap out some bullet points there. But that’s not how hiring actually works.

    Every company is hiring to solve a specific risk:

    • Startups are afraid their product won’t get built fast enough
    • Fintech companies are terrified of transaction failures
    • SaaS platforms need someone who can scale infrastructure
    • Enterprises want someone who won’t break existing systems

    Your resume needs to signal that you understand their specific fear and know how to address it.

    The Solution: Job Archetypes

    Instead of treating every job posting as a unique snowflake, I built a classification system around seven hiring archetypes:

    type JobArchetype =
      | 'STARTUP_BUILDER'              // Ship fast, iterate, wear many hats
      | 'ENTERPRISE_ENGINEER'          // Process, compliance, documentation
      | 'SAAS_PLATFORM_ENGINEER'       // Scale, reliability, multi-tenancy
      | 'FINTECH_TRANSACTION_ENGINEER' // Correctness, security, audit trails
      | 'FRONTEND_PRODUCT_ENGINEER'    // UX, polish, user-facing features
      | 'BACKEND_INFRA_ENGINEER'       // Systems, performance, architecture
      | 'FULLSTACK_GENERALIST'         // Breadth over depth, context switching
    

    Each archetype maps to a different resume weighting strategy:

    interface ResumeWeighting {
      emphasize: string[]      // Skills/experiences to highlight
      deEmphasize: string[]    // Things to downplay or remove
      toneBias: string         // Overall vibe of the application
    }
    

    For example, if the system classifies a job as STARTUP_BUILDER, it might return:

    {
      emphasize: [
        "shipped products end-to-end",
        "worked in small teams",
        "made technical decisions independently"
      ],
      deEmphasize: [
        "committee-based decisions",
        "long planning cycles"
      ],
      toneBias: "ownership_and_speed"
    }
    

    How Classification Actually Works

    The classification happens through structured LLM output using function calling. Instead of asking the LLM to “analyze this job posting,” I give it a specific procedure:

    const CLASSIFY_TOOL = {
      type: 'function',
      function: {
        name: 'save_archetype_classification',
        parameters: {
          primary_archetype: { 
            type: 'string', 
            enum: ARCHETYPE_VALUES 
          },
          secondary_archetype: { 
            type: 'string', 
            enum: [...ARCHETYPE_VALUES, 'NONE'] 
          },
          confidence: { 
            type: 'number',
            description: 'Classification confidence from 0 to 100'
          },
          emphasize: { 
            type: 'array', 
            items: { type: 'string' } 
          },
          // ... etc
        }
      }
    }
    

    The system prompt instructs the LLM to score each archetype by counting signal matches:

    • Trigger keyword match: +2 points
    • Context signal match: +3 points
    • Negative signal match: -2 points

    This scoring approach keeps classifications consistent across runs and makes the reasoning auditable.

    Research Phase: The Missing Piece

    Here’s what I realized: job descriptions alone aren’t enough. A generic “full-stack engineer” posting could be any archetype depending on the company context.

    So I added a research phase that runs between job discovery and classification:

    export class ResearchJobUseCase {
      async execute(jobId: string): Promise {
        const job = await this.jobRepository.findById(jobId);
        
        // Extract or resolve company website URL
        const websiteUrl = await this.resolveCompanyUrl(job);
        
        // Check if we already have recent research
        let research = await this.researchRepo.findByWebsite(websiteUrl);
        
        if (!research || this.isStale(research)) {
          // Scrape company website and analyze
          research = await this.llmProvider.researchCompany(websiteUrl);
          await this.researchRepo.save(research);
        }
        
        // Now classify with company context
        const archetype = await this.llmProvider.classifyArchetype(
          job.description,
          research.summary
        );
        
        job.classify(archetype);
        await this.jobRepository.save(job);
      }
    }
    

    The company research extraction pulls things like:

    • Tech stack mentioned on their site
    • Recent product launches or news
    • Company size and funding stage
    • Culture values from their about page

    This context dramatically improves classification accuracy. A job posting that just says “React engineer needed” becomes much clearer when you know the company is a Series A fintech startup that just raised $20M.

    Model Selection: The Surprising Winner

    I built a POC to compare three models on the classification task:

    • GLM-4.7-Flash (cheap, fast)
    • GLM-5 (expensive, supposedly better)
    • Kimi K2.5 (middle ground)
    const MODELS: ModelConfig[] = [
      {
        name: 'GLM-4.7-Flash',
        inputCostPer1M: 0.07,
        outputCostPer1M: 0.40,
      },
      {
        name: 'GLM-5',
        inputCostPer1M: 1.0,
        outputCostPer1M: 3.2,
      },
      // ...
    ];
    

    GLM-4.7-Flash won. Not because it was marginally better at classification, but because the cost difference meant I could add the company research step without blowing the budget. Better context with a cheaper model beats marginally better reasoning with worse context.

    Implementation Details That Matter

    Separate LLM for classification: The classification model can be different from the resume parsing model. This lets you optimize cost vs. quality for each task:

    this.classifierClient = new OpenAI({
      apiKey: process.env.CLASSIFIER_API_KEY || process.env.LLM_API_KEY,
      baseURL: process.env.CLASSIFIER_BASE_URL || process.env.LLM_BASE_URL,
    });
    

    Status tracking: Jobs now move through a RESEARCHING status before classification. This makes the pipeline debuggable and lets you see where jobs get stuck.

    Cached company research: Company data is stored in a separate table and reused across jobs. If you’ve researched Google once, you don’t need to do it again for every Google job posting.

    Company URL resolution: Many job boards don’t include the company website. I added a resolver service that checks Redis cache first, then tries to extract it from the job description or company name.

    What This Actually Enables

    With jobs properly classified, the next step becomes possible: dynamically reweighting resume content based on the archetype.

    Instead of one generic resume for everything, the system can now:

    • Reorder bullet points to emphasize relevant experience
    • Adjust language to match the company’s risk profile
    • Skip or condense sections that don’t matter for this archetype

    A financial services job gets your “implemented distributed transaction handling” experience front and center. A startup job leads with “shipped MVP in 3 weeks.”

    Same experience, different story.

    The Tradeoff

    This approach adds latency and cost to the job processing pipeline. Every job now requires:
    1. Company URL resolution (cached)
    2. Company research (cached per company, not per job)
    3. Archetype classification (per job)

    But the alternative is sending the same resume to every company and hoping keyword matching works. It doesn’t. This is the cost of actually understanding what companies want.

  • 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.

  • Building a Multi-Provider Auth System: From Theory to Working Code

    When you’re building a platform that connects developers across multiple services—GitHub, Twitter, LinkedIn—authentication becomes complicated fast. You can’t just slap OAuth on it and call it done. You need to think about how users authenticate, how you store their connections, and what happens when they want to link accounts across platforms.

    This is the story of setting up authentication infrastructure for CommitLore, a platform that transforms git commits into developer stories. The specific challenge: supporting both OAuth authentication (starting with GitHub) and multiple connected accounts to social platforms, all while keeping the architecture clean enough to add more providers without breaking everything.

    The Core Problem: Identity Fragmentation

    Here’s what we were facing:

    • Users authenticate via GitHub (OAuth)
    • Users want to connect Twitter, LinkedIn, and Dev.to accounts to auto-publish stories
    • Each provider has different authentication methods (API keys, browser extensions, app passwords)
    • A user might have multiple identities across providers
    • We needed to store credentials securely without making the codebase a mess of if-statements

    Most tutorials show you how to do single-provider OAuth. But when you’re dealing with multiple identity systems, you quickly realize the pattern needs to be more sophisticated.

    The Architecture Decision: Separating Concerns

    Instead of having authentication logic scattered across controllers, we split it into three distinct layers:

    Domain Layer — The contract that everything else builds on:

    // src/modules/auth/domain/interfaces/token-service.interface.ts
    export interface ITokenService {
      /
    
    • Generate access and refresh tokens for a user
    */ generateTokens(user: { id: string; email?: string; githubId?: string }): Promise;

    /

    • Verify an access token and return its payload
    */ verifyAccessToken(token: string): Promise;

    /

    • Verify a refresh token and return its payload
    */ verifyRefreshToken(token: string): Promise; }

    export const TOKEN_SERVICE = 'ITokenService';

    By defining the interface first, we made it possible to swap implementations later. Testing? Mock the service. Need a different token strategy? Implement the interface differently. This is dependency injection done right.

    Application Layer — The use cases that represent actual business logic:

    // src/modules/auth/application/use-cases/github-callback.use-case.ts
    @Injectable()
    export class GitHubCallbackUseCase {
      constructor(
        private findOrCreateFromOAuthUseCase: FindOrCreateFromOAuthUseCase,
        @Inject(TOKEN_SERVICE)
        private tokenService: ITokenService,
      ) {}

    async execute(githubUser: GitHubUserData): Promise { // Prepare OAuth data const oauthData: OAuthUserData = { provider: AuthProvider.GITHUB, providerUserId: githubUser.id.toString(), providerEmail: githubUser.email, displayName: githubUser.displayName, avatarUrl: githubUser.avatarUrl, isEmailVerified: !!githubUser.email, providerMetadata: { accessToken: githubUser.accessToken, username: githubUser.username, }, };

    // Find or create user const user = await this.findOrCreateFromOAuthUseCase.execute(oauthData);

    // Generate tokens const tokens = await this.tokenService.generateTokens({ id: user.id, email: user.email, githubId: githubUser.id.toString(), });

    return { user: { id: user.id, email: user.email, displayName: user.displayName, avatarUrl: user.avatarUrl, }, tokens, }; } }

    Notice what’s happening here: we’re not handling tokens, not talking to the database, not validating GitHub responses. We’re orchestrating. The use case says: “When GitHub calls us back, find or create a user, then give them tokens.” That’s it.

    Infrastructure Layer — The actual implementations:

    // src/modules/auth/infrastructure/services/token.service.ts
    @Injectable()
    export class TokenService implements ITokenService {
      constructor(
        private jwtService: JwtService,
        private configService: ConfigService,
      ) {}

    async generateTokens(user: { id: string; email?: string; githubId?: string }): Promise { const payload: TokenPayload = { sub: user.id, email: user.email, githubId: user.githubId, };

    // Generate access token (short-lived - 15 minutes) const accessToken = this.jwtService.sign(payload, { secret: this.configService.get('JWT_SECRET'), expiresIn: '15m', });

    // Generate refresh token (long-lived - 7 days) const refreshToken = this.jwtService.sign(payload, { secret: this.configService.get('JWT_REFRESH_SECRET'), expiresIn: '7d', });

    return { accessToken, refreshToken, }; }

    async verifyAccessToken(token: string): Promise { try { const payload = this.jwtService.verify(token, { secret: this.configService.get('JWT_SECRET'), }); return payload; } catch { return null; } }

    // ... refresh token verification }

    The Database Design: Future-Proof Identity Storage

    Here’s where the multi-provider support becomes tangible. We have three key tables:

    Users — The core user record:

    CREATE TABLE "users" (
        "id" uuid NOT NULL DEFAULT uuid_generate_v4(),
        "createdAt" TIMESTAMP NOT NULL DEFAULT now(),
        "updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
        "email" character varying(255),
        "displayName" character varying(255),
        "avatarUrl" text,
        "isActive" boolean NOT NULL DEFAULT true,
        CONSTRAINT "UQ_users_email" UNIQUE ("email"),
        CONSTRAINT "PK_users_id" PRIMARY KEY ("id")
    )
    

    Auth Identities — The multi-provider links:

    CREATE TABLE "auth_identities" (
        "id" uuid NOT NULL DEFAULT uuid_generate_v4(),
        "userId" uuid NOT NULL,
        "provider" character varying(50) NOT NULL,
        "providerUserId" character varying(255) NOT NULL,
        "providerEmail" character varying(255),
        "passwordHash" text,
        "providerMetadata" jsonb,
        CONSTRAINT "FK_auth_identities_userId" FOREIGN KEY ("userId") 
            REFERENCES "users"("id") ON DELETE CASCADE,
        CONSTRAINT "UQ_provider_providerUserId" UNIQUE ("provider", "providerUserId")
    )
    

    The key insight: one user can have multiple auth identities. A user might log in via GitHub, but we’re already prepared for them to authenticate via Google or email+password later.

    Connected Accounts — The social platforms:

    CREATE TYPE "platform_enum" AS ENUM ('twitter', 'linkedin', 'wordpress', 'devto');
    CREATE TYPE "connection_method_enum" AS ENUM ('api_credentials', 'browser_extension', 'app_password', 'api_key');

    CREATE TABLE "connected_accounts" ( "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "platform" "platform_enum" NOT NULL, "connectionMethod" "connection_method_enum" NOT NULL, "displayName" character varying(255), "profileUrl" character varying(500), "isActive" boolean NOT NULL DEFAULT true, "credentials" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "FK_connected_accounts_userId" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE, CONSTRAINT "UQ_userId_platform" UNIQUE ("userId", "platform") )

    This separation means:

    • A user authenticates once via GitHub
    • They can connect unlimited social platforms
    • Each platform can use different credential types
    • The data structure scales without refactoring

    What Made This Click

    The commit includes 61 files and roughly 28k lines. That’s not because it’s bloated—it’s because the architecture had to be right from the start. Here’s why:

    1. Token Strategy is Pluggable — We’re using a 15-minute access token + 7-day refresh token pattern. But if we needed to switch to session-based auth or a different token library? The interface stays the same. The implementation changes.

    2. OAuth Integration is Modular — GitHub is handled through Passport strategies, but the business logic (find or create user) is completely separate from the OAuth mechanism. Adding Twitter OAuth means implementing a new strategy, not rewriting the user service.

    3. Database Migrations are Explicit — Instead of relying on ORM synchronization (which breaks in production), each schema change is a timestamped migration. This matters when you have multiple providers with different credential formats.

    4. Identity Providers are Enumerated — By using TypeScript enums and database enums for provider types, we prevent invalid states. No accidental typos like “githb” sneaking into production.

    What Didn’t Make It (And Why)

    This commit doesn’t include the actual repository sync logic, webhook handlers, or the frontend. Those come in separate PRs. There’s a reason: this is foundational. Authentication can’t be done incrementally. You build it solid, deploy it, then layer features on top.

    We also didn’t include email/password authentication. GitHub OAuth was the MVP. Adding email later will be trivial because we already have AuthProvider.EMAIL ready to go in the enum.

    The Moment It Clicked

    When you can write a use case like this and not think about tokens, database queries, or HTTP details—that’s when you know the architecture is right:

    async execute(githubUser: GitHubUserData): Promise {
      const oauthData: OAuthUserData = { / ... / };
      const user = await this.findOrCreateFromOAuthUseCase.execute(oauthData);
      const tokens = await this.tokenService.generateTokens({ / ... / });
      
      return { user, tokens };
    }
    

    This is pure business logic. No framework noise. No SQL. No HTTP concerns. That’s the point.

    For Your Next Project

    If you’re building multi-provider auth, steal this pattern:

    1. Define interfaces first — Know what your token service needs to do before you implement it
    2. Separate identity providers from connected accounts — They’re different concerns
    3. Use migrations for schema changes — Even if it feels slower upfront
    4. Keep use cases simple — If your use case is orchestrating more than 3 things, split it
    5. Plan for multiple providers early — Even if you only implement one initially

    The code doesn’t need to be complex. The thinking needs to be clear.

  • Adding WordPress to a Multi-Platform Publishing System

    When you’re building a system that publishes to multiple platforms, each new integration reveals something about your architecture. WordPress was the fourth platform I added to our auto-publishing API, and it exposed a design decision I’d made earlier that was both helping and limiting me.

    The Pattern That Emerged

    Looking at the existing publishers – Twitter, Dev.to, and LinkedIn – they all followed the same interface:

    interface IPublisher {
      supports(platform: Platform): boolean;
      publish(request: PublishRequest): Promise;
    }
    

    The registry pattern made adding WordPress straightforward: implement the interface, register the service, add it to the supported platforms list. Four files changed, mostly boilerplate.

    But here’s what got interesting: the prompt builder.

    The Prompt Builder Problem

    Originally, I had platform-specific prompts embedded directly in the prompt builder – detailed guidelines for each platform’s voice and format. Twitter had examples of “good dev tweets.” LinkedIn had strict rules about what not to say. It worked, but it was getting unwieldy.

    The old approach looked like this:

    [Platform.TWITTER]: {
        maxLength: 280,
        format: 'A single tweet',
        guidelines: 
    Under 280 characters. No emojis. No hashtags unless essential (max 1).

    Write like these real dev tweets:

    • "finally mass assigned where I didn't mean to"
    • "the bug was in the config. it's always in the config"
    ... Notice: short, lowercase ok, no "lesson learned", no wisdom...
    , }

    This was 120+ lines of platform rules. Adding WordPress would’ve meant another 40-50 lines of guidelines.

    The Refactor

    Instead of adding more guidelines, I simplified the entire prompt system:

    private readonly platformConstraints: Record = {
      [Platform.WORDPRESS]: {
        maxLength: 10000,
        formatNote: 'Blog post. Start with # Title. Use ## for sections. Include code blocks where relevant.',
        voice: Blog voice: Teach through story. Explain the problem, your approach, what worked and didn't. Include code where helpful. Write for someone who might face the same problem.,
      },
      // ... other platforms
    }
    

    Went from 451 lines to 48 in the prompt builder. The detailed examples and rules? Moved them to the system prompt that gets sent to the LLM once, not repeated for every platform config.

    What Actually Changed

    The WordPress publisher itself is straightforward – authenticate with WordPress REST API, convert markdown to HTML, handle the various HTTP error cases. The interesting part was that adding it forced me to see that the prompt builder had become a dumping ground for instructions that belonged elsewhere.

    Sometimes you don’t see the mess until you’re about to add to it.

    The markdown-to-HTML conversion is basic for now:

    private markdownToHtml(markdown: string): string {
      let html = markdown;
      
      // Remove title line if it starts with #
      const lines = html.split('\n');
      if (lines[0].startsWith('# ')) {
        html = lines.slice(1).join('\n');
      }
      
      // Headers
      html = html.replace(/^### (.*$)/gim, '

    $1

    '); html = html.replace(/^## (.*$)/gim, '

    $1

    '); // Code blocks html = html.replace(/

    (\w+)?\n([\s\S]*?)“/g,
    '

    $2

    ');

    // Paragraphs
    html = html.split('\n\n')
    .map(para =>

    ${para.trim()}

    )
    .join('\n');

    return html;
    }
    “

    This’ll need a proper markdown parser eventually, but it works for the generated content format we’re producing.

    The Tradeoff

    The refactor made the code cleaner but shifted complexity. Now the LLM gets more context up front in the system prompt instead of having it repeated in every platform config. That’s probably better – but it means the quality of the output depends more on prompt engineering in one place rather than being distributed across platform configs.

    Not sure yet if that’s the right call, but adding WordPress made it clear the old approach wasn’t scaling.

  • The Architecture of User Trust: Building an Activity Feed That Actually Matters

    When you’re building a developer tool that touches people’s GitHub repos and social media accounts, trust isn’t optional—it’s the product. Every encrypted webhook secret, every logged action, every verification step is answering one question: “Should I let this thing touch my stuff?”

    I recently shipped a complete audit trail system for our backend API. Not because it’s sexy (it’s not), not because users asked for it (they didn’t), but because when you’re handling GitHub webhooks and auto-posting to Twitter, people deserve to know exactly what happened and when.

    The Problem: Invisible Actions Are Scary Actions

    Here’s what we had before: background jobs processing commits, AI generating drafts, accounts getting connected. All working fine. All completely invisible.

    A user would come back hours later to find tweets posted from their account with no record of what triggered them. Or they’d see their webhook secret had changed and have no idea if that was them or the system. Or worse—something would fail silently and they’d just… wonder.

    That wondering is poison for a developer tool. Developers don’t trust black boxes.

    The Solution: Activity as a First-Class Resource

    The core insight was treating activities like any other domain entity—with proper enums, relationships, and metadata:

    export enum ActivityType {
      // Draft activities
      DRAFT_GENERATED = 'draft_generated',
      DRAFT_APPROVED = 'draft_approved',
      DRAFT_PUBLISHED = 'draft_published',
      
      // Repository activities
      REPOSITORY_LINKED = 'repository_linked',
      REPOSITORY_SYNCED = 'repository_synced',
      
      // Account activities
      ACCOUNT_CONNECTED = 'account_connected',
      ACCOUNT_VERIFIED = 'account_verified',
      
      // System activities
      WEBHOOK_RECEIVED = 'webhook_received',
      COMMIT_PROCESSED = 'commit_processed',
    }
    

    Each activity captures not just what happened, but why it matters:

    @Entity('activities')
    export class Activity extends BaseEntity {
      @Column({ type: 'enum', enum: ActivityType })
      type: ActivityType;

    @Column({ type: 'varchar', length: 500 }) description: string;

    @Column({ type: 'enum', enum: ResourceType, nullable: true }) resourceType?: ResourceType;

    @Column({ type: 'uuid', nullable: true }) resourceId?: string;

    @Column({ type: 'jsonb', nullable: true }) metadata?: ActivityMetadata;

    @Column({ type: 'boolean', default: false }) isAutomated: boolean; }

    That isAutomated flag is crucial. Users don’t just want to know what happened—they want to know if they did it or if the AI did it.

    The Encryption Problem Nobody Talks About

    While building the activity system, I hit a related trust problem: credentials were sitting in the database as plain JSON. Twitter API keys, LinkedIn tokens, all just… there.

    The migration to encrypted credentials revealed something interesting about backward compatibility:

    export class EncryptWebhookSecrets1764102300000 implements MigrationInterface {
      public async up(queryRunner: QueryRunner): Promise {
        // Increase column length for encrypted format:
        // base64(IV):base64(authTag):base64(ciphertext)
        await queryRunner.query(
          ALTER TABLE "repositories"
          ALTER COLUMN "webhookSecret" TYPE character varying(500)
        );

    // Note: Existing plaintext secrets continue to work // The transformer detects encryption before decrypting } }

    The transformer handles both encrypted and plaintext values transparently. This let us migrate without downtime—old secrets keep working while new ones get encrypted automatically.

    The Index Strategy That Makes It Fast

    An activity feed is useless if it’s slow. The query pattern is predictable: users want their recent activities, optionally filtered by type or resource.

    @Index(['userId', 'createdAt'])
    @Index(['userId', 'type'])
    export class Activity extends BaseEntity {
      // Composite index for the common case:
      // "Show me my recent activities"
      
      // Type-specific index for:
      // "Show me all my draft generations"
    }
    

    The repository builds queries that leverage these indexes:

    async findByUserId(
      userId: string,
      filters?: ActivityFilters,
      limit = 20,
      offset = 0,
    ): Promise {
      const query = this.activityRepo.createQueryBuilder('activity')
        .where('activity.userId = :userId', { userId })
        .orderBy('activity.createdAt', 'DESC')
        .skip(offset)
        .take(limit);

    if (filters?.type) { query.andWhere('activity.type = :type', { type: filters.type }); } return query.getMany(); }

    The default query hits the (userId, createdAt) index. Add a type filter and it switches to (userId, type). PostgreSQL’s query planner handles the rest.

    The Metadata Pattern for Flexible Context

    Different activities need different context. A webhook event needs the commit SHA. A draft generation needs the platform name. A repository sync needs the branch.

    Rather than creating activity subtypes (a maintenance nightmare), we use typed metadata:

    export interface ActivityMetadata {
      platform?: string;
      repositoryName?: string;
      commitSha?: string;
      draftTitle?: string;
      accountDisplayName?: string;
      error?: string;
      [key: string]: unknown;
    }
    

    The [key: string]: unknown lets each activity type add whatever makes sense:

    await this.logActivityUseCase.execute({
      userId: user.id,
      type: ActivityType.DRAFT_GENERATED,
      description: 'Generated draft for Twitter',
      resourceType: ResourceType.DRAFT,
      resourceId: draft.id,
      metadata: {
        platform: 'twitter',
        commitSha: commit.sha,
        repositoryName: repository.name,
      },
      isAutomated: true,
    });
    

    PostgreSQL’s JSONB handles the variable structure efficiently while keeping queries simple.

    What Didn’t Work: The Verification Rabbit Hole

    The initial plan included automatic credential verification on a schedule. Test the Twitter API every hour, verify LinkedIn tokens before each post, that sort of thing.

    This turned out to be a terrible idea for three reasons:

    1. Rate limits: Twitter’s API doesn’t appreciate you testing auth every hour
    2. False positives: Network blips would flag working credentials as broken
    3. User confusion: “Why is my account showing as unverified when it worked fine yesterday?”

    The final approach is simpler: verify on-demand when the user requests it, and log that verification as an activity. If a credential fails during actual use, that’s also logged as an activity.

    Trust through transparency, not through constant testing.

    The Integration Point Nobody Expects

    The real power of an activity system shows up when you don’t build it as a separate feature. It’s infrastructure that other features use:

    // In the disconnect account use case
    await this.logActivityUseCase.execute({
      userId,
      type: ActivityType.ACCOUNT_DISCONNECTED,
      description: Disconnected ${account.displayName} from ${account.platform},
      resourceType: ResourceType.ACCOUNT,
      resourceId: account.id,
      metadata: {
        platform: account.platform,
        accountDisplayName: account.displayName,
      },
      isAutomated: false,
    });
    

    Every important action in the system now leaves a trail. Users can see exactly what happened, when, and whether it was them or the automation.

    The Architecture Lesson

    Building trust into developer tools isn’t about perfect security or zero bugs. It’s about visibility. When something goes wrong (and it will), users need to be able to figure out what happened without emailing support.

    The activity feed gives them that. Every webhook received, every credential updated, every draft published—all visible, all searchable, all explained.

    That’s not just good UX. For a tool that auto-posts to social media, it’s existential. Users won’t trust a system they can’t audit.

  • Publishing to LinkedIn Without Their API: A Browser Automation Story

    I just spent the better part of a day making a bot pretend to be me on LinkedIn. Not because I’m lazy (okay, partly), but because LinkedIn’s official API is… let’s just say it has opinions about what you can and can’t do.

    The goal was simple: let users publish posts to LinkedIn from our app. The reality? LinkedIn’s API requires company verification, explicit approval, and even then, you can only post as company pages, not personal profiles. For a product where developers want to share their commits as personal updates, that’s a non-starter.

    So I did what any reasonable developer would do: I automated a browser to click buttons like a human would.

    The Cookie Heist

    The approach is almost embarrassingly straightforward. I built a browser extension that steals your LinkedIn session cookies when you click “connect.” Not in a sketchy way—you explicitly authorize it—but yeah, we’re basically lifting your active session and replaying it later.

    const liAtCookie = cookies.find((c) => c.name === 'li_at');
    const jSessionCookie = cookies.find((c) => c.name === 'JSESSIONID');

    if (!liAtCookie || !jSessionCookie) { throw new BadRequestException( 'Missing required LinkedIn session cookies. Please ensure you are logged into LinkedIn.', ); }

    The li_at cookie is LinkedIn’s session token. Get that, and you can make authenticated requests. The extension captures it along with your browser’s user agent, stores it encrypted in the database, and we’re off to the races.

    Playwright vs. LinkedIn’s UI

    Here’s where it gets fun. LinkedIn changes their UI constantly. Like, I’m talking selector classes that look like someone smashed their keyboard: .share-box-feed-entry__closed-share-box. And they change them.

    I ended up writing a selector gauntlet—try ten different ways to find the “Start a post” button:

    const startPostSelectors = [
      '[placeholder="Start a post"]',
      'div[data-placeholder="Start a post"]',
      '.share-box-feed-entry__top-bar',
      '.share-box-feed-entry__closed-share-box',
      '.share-creation-state__text-field',
      'div[role="button"]:has-text("Start a post")',
      // ... 4 more
    ];

    let startPostButton = null; for (const selector of startPostSelectors) { try { startPostButton = await page.waitForSelector(selector, { timeout: 2000 }); if (startPostButton && await startPostButton.isVisible()) { break; } } catch { continue; } }

    It’s ugly. It’s fragile. It works.

    The Part That Broke

    Typing the post content took three attempts to get right. First try: just page.fill() the textarea. Didn’t work—LinkedIn needs to see actual keypress events or it won’t enable the Post button.

    Second try: page.type() with a delay between characters. Better, but the UI would sometimes lose focus and the text would end up… nowhere. Just gone.

    Third try (the one that worked): force focus, clear any existing text, wait for the editor to be ready, then type with delays AND explicitly trigger input events:

    await textArea.evaluate((el: any) => {
      if (el.innerText) el.innerText = '';
      if (el.textContent) el.textContent = '';
    });

    await page.waitForTimeout(500); await textArea.focus(); await page.waitForTimeout(300); await textArea.type(request.content, { delay: 50 }); await page.waitForTimeout(500);

    Those waitForTimeout calls? Pure cargo-culting at this point. I don’t know exactly why 500ms works better than 300ms, but it does, and I’ve made peace with that.

    The Paranoia Layer

    LinkedIn’s bot detection is decent. They look for:

    • Unrealistic typing speeds (hence the 50ms delay per character)
    • Missing user agents
    • Cookie/IP mismatches
    • Suspicious timing patterns

    I’m not trying to bypass security here—users are authenticating legitimately through the extension. But the automation still needs to look human enough that LinkedIn doesn’t freak out and invalidate the session.

    The browser runs in non-headless mode during development so I can watch it work. In production, I’m honestly not sure if headless will survive long-term. Might need to run headed browsers in a VNC environment. Future me’s problem.

    What I’m Not Sure About

    Session expiry is tracked by the li_at cookie’s expiration timestamp, but I don’t actually know if LinkedIn invalidates sessions earlier than that. The code checks it:

    if (credentials.expiresAt && new Date(credentials.expiresAt) < new Date()) {
      return {
        success: false,
        error: 'LinkedIn session has expired. Please reconnect via the browser extension.',
      };
    }
    

    But I suspect LinkedIn might kill sessions based on IP changes, long inactivity, or who knows what else. We'll find out when users start reporting failures.

    Why This Feels Wrong But Isn't

    There's a voice in my head saying "this is going to break constantly" and "LinkedIn will hate this." Both probably true.

    But here's the thing: users own their accounts. They're authenticating. They're choosing what to post. We're just automating the clicking because LinkedIn won't let us use an API for personal posts.

    Is it fragile? Absolutely. Will I be updating those selectors every few months? Probably. But it ships the feature users actually need, and sometimes that's what matters.

    The full diff is 893 lines of "please don't break" energy, but it works. I watched it click through the LinkedIn UI, type out a post, and hit publish. Felt like watching a Rube Goldberg machine succeed.

    Now I wait for the first bug report.