[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
Use Cases/Build a Changelog API with In-App Announcements

Build a Changelog API with In-App Announcements

Build a changelog system with versioned entries, in-app announcement widgets, read tracking, subscriber notifications, and a public changelog page — keeping users informed about product updates.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 6 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

postgresql

v1.0.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

87/100 quality
1.53× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
View skill
>

nextjs

v1.0.0

Assists with building production-grade React applications using Next.js. Use when working with the App Router, Server Components, Server Actions, Middleware, or deploying to Vercel or self-hosted environments. Trigger words: nextjs, next.js, app router, server components, server actions, react framework, ssr, isr.

93/100 quality
1.16× impact
SAFE
View skill
$

The Problem

Ryo leads product at a 25-person SaaS. They ship 3-5 features per week but users don't know about them. The team posts updates on Twitter and a blog, but most users never see them. Support gets tickets asking for features that already exist. There's no in-app way to notify users about what's new. They need a changelog system that announces updates inside the app, tracks who's seen what, lets users subscribe to categories they care about, and powers a public changelog page for prospects.

Step 1: Build the Changelog System

typescript
// src/changelog/engine.ts — Changelog with in-app widget, read tracking, and notifications
import { pool } from "../db";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

interface ChangelogEntry {
  id: string;
  title: string;
  slug: string;
  body: string;                 // markdown
  bodyHtml: string;
  category: "feature" | "improvement" | "fix" | "announcement";
  tags: string[];
  imageUrl: string | null;
  version: string | null;       // e.g., "2.4.0"
  publishedAt: string;
  status: "draft" | "published" | "scheduled";
  scheduledFor: string | null;
  authorId: string;
  authorName: string;
  reactionCounts: Record<string, number>;
}

// Create or update a changelog entry
export async function upsertEntry(
  entry: Omit<ChangelogEntry, "id" | "bodyHtml" | "reactionCounts"> & { id?: string }
): Promise<ChangelogEntry> {
  const id = entry.id || `cl-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
  const bodyHtml = renderMarkdown(entry.body);

  const { rows: [result] } = await pool.query(
    `INSERT INTO changelog_entries (id, title, slug, body, body_html, category, tags, image_url, version, published_at, status, scheduled_for, author_id)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
     ON CONFLICT (id) DO UPDATE SET
       title = EXCLUDED.title, slug = EXCLUDED.slug, body = EXCLUDED.body, body_html = EXCLUDED.body_html,
       category = EXCLUDED.category, tags = EXCLUDED.tags, image_url = EXCLUDED.image_url,
       version = EXCLUDED.version, status = EXCLUDED.status, scheduled_for = EXCLUDED.scheduled_for,
       updated_at = NOW()
     RETURNING *`,
    [id, entry.title, entry.slug, entry.body, bodyHtml, entry.category,
     JSON.stringify(entry.tags), entry.imageUrl, entry.version,
     entry.publishedAt || new Date().toISOString(), entry.status,
     entry.scheduledFor, entry.authorId]
  );

  // If publishing, notify subscribers
  if (entry.status === "published") {
    await notifySubscribers(result);
    await redis.del("changelog:latest");
  }

  return result;
}

// Get changelog feed (public or in-app)
export async function getFeed(options?: {
  category?: string;
  page?: number;
  limit?: number;
  since?: string;              // ISO date — only entries after this date
}): Promise<{ entries: ChangelogEntry[]; total: number; hasMore: boolean }> {
  const limit = options?.limit || 10;
  const offset = ((options?.page || 1) - 1) * limit;

  const conditions = ["status = 'published'", "published_at <= NOW()"];
  const params: any[] = [];
  let idx = 1;

  if (options?.category) {
    conditions.push(`category = $${idx++}`);
    params.push(options.category);
  }
  if (options?.since) {
    conditions.push(`published_at > $${idx++}`);
    params.push(options.since);
  }

  const where = conditions.join(" AND ");

  const [entries, count] = await Promise.all([
    pool.query(
      `SELECT e.*, u.name as author_name FROM changelog_entries e
       JOIN users u ON e.author_id = u.id
       WHERE ${where}
       ORDER BY published_at DESC
       LIMIT $${idx} OFFSET $${idx + 1}`,
      [...params, limit, offset]
    ),
    pool.query(`SELECT COUNT(*) as total FROM changelog_entries WHERE ${where}`, params),
  ]);

  // Get reaction counts
  for (const entry of entries.rows) {
    const reactions = await redis.hgetall(`changelog:reactions:${entry.id}`);
    entry.reactionCounts = Object.fromEntries(
      Object.entries(reactions).map(([k, v]) => [k, parseInt(v)])
    );
  }

  return {
    entries: entries.rows,
    total: parseInt(count.rows[0].total),
    hasMore: offset + limit < parseInt(count.rows[0].total),
  };
}

// In-app widget: get unread count and latest entries for a user
export async function getWidgetData(userId: string): Promise<{
  unreadCount: number;
  latestEntries: Array<ChangelogEntry & { isRead: boolean }>;
}> {
  // Get user's last read timestamp
  const lastRead = await redis.get(`changelog:lastread:${userId}`);
  const lastReadDate = lastRead || "2000-01-01";

  // Get latest entries
  const { rows: entries } = await pool.query(
    `SELECT e.*, u.name as author_name FROM changelog_entries e
     JOIN users u ON e.author_id = u.id
     WHERE e.status = 'published' AND e.published_at <= NOW()
     ORDER BY e.published_at DESC
     LIMIT 10`
  );

  // Count unread
  const { rows: [{ count }] } = await pool.query(
    `SELECT COUNT(*) as count FROM changelog_entries
     WHERE status = 'published' AND published_at > $1 AND published_at <= NOW()`,
    [lastReadDate]
  );

  return {
    unreadCount: parseInt(count),
    latestEntries: entries.map((e) => ({
      ...e,
      isRead: new Date(e.published_at) <= new Date(lastReadDate),
    })),
  };
}

// Mark changelog as read
export async function markAsRead(userId: string): Promise<void> {
  await redis.set(`changelog:lastread:${userId}`, new Date().toISOString());
}

// React to an entry
export async function addReaction(entryId: string, userId: string, emoji: string): Promise<Record<string, number>> {
  const key = `changelog:reactions:${entryId}`;
  const userKey = `changelog:userreaction:${entryId}:${userId}`;

  // Check if user already reacted with this emoji
  const existing = await redis.get(userKey);
  if (existing === emoji) {
    // Toggle off
    await redis.hincrby(key, emoji, -1);
    await redis.del(userKey);
  } else {
    // Remove old reaction
    if (existing) {
      await redis.hincrby(key, existing, -1);
    }
    // Add new reaction
    await redis.hincrby(key, emoji, 1);
    await redis.set(userKey, emoji);
  }

  const reactions = await redis.hgetall(key);
  return Object.fromEntries(Object.entries(reactions).map(([k, v]) => [k, parseInt(v)]).filter(([, v]) => v > 0));
}

// Subscriber management
export async function subscribe(userId: string, categories: string[]): Promise<void> {
  await pool.query(
    `INSERT INTO changelog_subscriptions (user_id, categories, created_at)
     VALUES ($1, $2, NOW())
     ON CONFLICT (user_id) DO UPDATE SET categories = $2`,
    [userId, JSON.stringify(categories)]
  );
}

// Notify subscribers of new entry
async function notifySubscribers(entry: any): Promise<void> {
  const { rows: subscribers } = await pool.query(
    `SELECT user_id FROM changelog_subscriptions
     WHERE categories @> $1 OR categories @> '["all"]'::jsonb`,
    [JSON.stringify([entry.category])]
  );

  for (const sub of subscribers) {
    await redis.rpush("notification:queue", JSON.stringify({
      userId: sub.user_id,
      type: "changelog_update",
      data: {
        entryId: entry.id,
        title: entry.title,
        category: entry.category,
      },
    }));
  }
}

// Publish scheduled entries (cron job)
export async function publishScheduled(): Promise<number> {
  const { rows } = await pool.query(
    `UPDATE changelog_entries SET status = 'published', published_at = NOW()
     WHERE status = 'scheduled' AND scheduled_for <= NOW()
     RETURNING *`
  );

  for (const entry of rows) {
    await notifySubscribers(entry);
  }

  await redis.del("changelog:latest");
  return rows.length;
}

function renderMarkdown(body: string): string { return body; }

Results

  • Feature awareness: 10% → 65% — in-app widget with unread badge means users see new features the next time they log in; no more "I didn't know this existed"
  • Support tickets for existing features: 15/week → 2 — users discover features through the changelog instead of asking support; widget shows exactly what's new
  • Engagement with updates measurable — reaction counts (🎉 🔥 👍) show which features users are excited about; PM uses this to prioritize roadmap
  • Public changelog converts prospects — "Look how fast they ship" — prospects see weekly releases on the changelog page; shows product velocity
  • Scheduled publishing — write entries during the week, schedule for Monday morning; consistent communication cadence without manual effort