Uncategorized

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

November 27, 20255 min read

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:

typescript
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:

typescript
@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:

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

typescript
@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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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.

Share this post
Aniefon Umanah

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.