[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
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.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 6 skills

Avg quality 97/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

postgresql

v1.1.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.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
>

nextjs

v1.1.0

Assists with building production-grade React applications using Next.js 16. Use when working with the App Router, Server Components, Server Actions, the proxy file (formerly middleware), Cache Components, or deploying to Vercel or self-hosted environments. Trigger words: nextjs, next.js, app router, server components, server actions, use cache, proxy.ts, react framework, ssr, isr.

100/100 quality
5.29× impact
SAFE· stale score
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

Related use cases