[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a Mobile Push Notification Service

Build a Mobile Push Notification Service

Build a push notification service that delivers 5M notifications/day across iOS and Android with personalization, A/B testing, quiet hours, and analytics — increasing app engagement by 35%.

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

Skills stack · 7 skills

Avg quality 95/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
>

bull-mq

v1.1.0

Runs background jobs in Node.js with BullMQ, a job queue library backed by Redis (or PostgreSQL since v6): delayed and prioritized jobs, retries with backoff, rate limiting, cron-style job schedulers, parent-child flows and concurrency control. Use when a user asks to add a background worker, queue emails or webhooks, schedule recurring jobs, retry failed jobs, rate limit an API client, or migrate from Bull or from BullMQ 5 repeatable jobs.

100/100 quality
1.19× 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
>

kafka

v1.1.0

Build event-driven systems with Apache Kafka. Use when a user asks to set up message streaming, implement event sourcing, build pub/sub systems, process real-time data streams, or connect microservices with async messaging.

87/100 quality
2.00× 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
>

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
$

The Problem

A mobile app with 2M users sends push notifications via a single Firebase function. No personalization — everyone gets the same message at the same time. No quiet hours — users in Tokyo get pinged at 3 AM. No analytics — the team doesn't know if notifications drive engagement or annoy users. Uninstall rate spiked 20% after a "blast everyone" campaign. The product team wants targeted, behavior-based notifications but the current system can only broadcast.

Step 1: Device Registry and Preferences

typescript
// src/push/registry.ts
import { z } from 'zod';
import { Pool } from 'pg';

const db = new Pool({ connectionString: process.env.DATABASE_URL });

const DeviceRegistration = z.object({
  userId: z.string(),
  deviceId: z.string(),
  platform: z.enum(['ios', 'android', 'web']),
  pushToken: z.string(),
  appVersion: z.string(),
  locale: z.string().default('en'),
  timezone: z.string().default('UTC'),
  preferences: z.object({
    enabled: z.boolean().default(true),
    categories: z.record(z.string(), z.boolean()).default({}),
    quietHoursStart: z.number().int().min(0).max(23).default(22),
    quietHoursEnd: z.number().int().min(0).max(23).default(8),
  }).default({}),
});

export async function registerDevice(data: z.infer<typeof DeviceRegistration>): Promise<void> {
  await db.query(`
    INSERT INTO device_tokens (user_id, device_id, platform, push_token, app_version, locale, timezone, preferences, updated_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
    ON CONFLICT (device_id) DO UPDATE SET
      push_token = $4, app_version = $5, locale = $6, timezone = $7, preferences = $8, updated_at = NOW()
  `, [data.userId, data.deviceId, data.platform, data.pushToken, data.appVersion, data.locale, data.timezone, JSON.stringify(data.preferences)]);
}

export async function getUserDevices(userId: string): Promise<z.infer<typeof DeviceRegistration>[]> {
  const { rows } = await db.query(
    `SELECT * FROM device_tokens WHERE user_id = $1 AND push_token IS NOT NULL`,
    [userId]
  );
  return rows.map(r => ({ ...r, preferences: r.preferences ?? {} }));
}

Step 2: Notification Dispatcher

typescript
// src/push/dispatcher.ts
import { Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { getUserDevices } from './registry';

const connection = new Redis(process.env.REDIS_URL!);
const pushQueue = new Queue('push-notifications', { connection });

interface PushRequest {
  userId: string;
  category: string;
  title: string;
  body: string;
  data?: Record<string, string>;
  imageUrl?: string;
  badge?: number;
  sound?: string;
  priority?: 'high' | 'normal';
  ttlSeconds?: number;
  deduplicationKey?: string;
}

export async function sendPush(request: PushRequest): Promise<void> {
  // Deduplication
  if (request.deduplicationKey) {
    const dedupKey = `push:dedup:${request.userId}:${request.deduplicationKey}`;
    const exists = await connection.set(dedupKey, '1', 'NX', 'EX', 3600);
    if (!exists) return;
  }

  const devices = await getUserDevices(request.userId);

  for (const device of devices) {
    // Check if user opted out of this category
    if (!device.preferences.enabled) continue;
    if (device.preferences.categories[request.category] === false) continue;

    // Check quiet hours
    const userHour = getCurrentHourInTimezone(device.timezone);
    const inQuietHours = isInQuietHours(userHour, device.preferences.quietHoursStart, device.preferences.quietHoursEnd);

    if (inQuietHours && request.priority !== 'high') {
      // Delay until quiet hours end
      const delayMs = msUntilHour(device.timezone, device.preferences.quietHoursEnd);
      await pushQueue.add('deliver', { request, device }, { delay: delayMs });
      continue;
    }

    await pushQueue.add('deliver', { request, device }, {
      attempts: 3,
      backoff: { type: 'exponential', delay: 5000 },
    });
  }
}

// Batch send to segment
export async function sendToSegment(
  segment: { sql: string; params: any[] },
  notification: Omit<PushRequest, 'userId'>
): Promise<{ queued: number }> {
  const { Pool } = await import('pg');
  const db = new Pool({ connectionString: process.env.DATABASE_URL });
  const { rows } = await db.query(segment.sql, segment.params);

  let queued = 0;
  for (const row of rows) {
    await sendPush({ ...notification, userId: row.user_id });
    queued++;
  }

  return { queued };
}

const worker = new Worker('push-notifications', async (job) => {
  const { request, device } = job.data;

  if (device.platform === 'ios' || device.platform === 'android') {
    await sendViaFCM(device.pushToken, {
      notification: {
        title: request.title,
        body: request.body,
        image: request.imageUrl,
      },
      data: request.data,
      android: {
        priority: request.priority === 'high' ? 'high' : 'normal',
        ttl: `${request.ttlSeconds ?? 86400}s`,
        notification: { sound: request.sound ?? 'default', channelId: request.category },
      },
      apns: {
        payload: {
          aps: { badge: request.badge, sound: request.sound ?? 'default', 'mutable-content': 1 },
        },
      },
    });
  }

  // Track delivery
  await connection.hincrby(`push:stats:${new Date().toISOString().split('T')[0]}`, 'delivered', 1);
  await connection.hincrby(`push:stats:${request.category}:${new Date().toISOString().split('T')[0]}`, 'delivered', 1);
}, { connection, concurrency: 100 });

async function sendViaFCM(token: string, message: any): Promise<void> {
  const accessToken = await getGoogleAccessToken();
  const res = await fetch(`https://fcm.googleapis.com/v1/projects/${process.env.FCM_PROJECT}/messages:send`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: { ...message, token } }),
  });
  if (!res.ok) {
    const error = await res.text();
    if (error.includes('NOT_FOUND') || error.includes('UNREGISTERED')) {
      // Token invalid — remove device
      const { Pool } = await import('pg');
      const db = new Pool({ connectionString: process.env.DATABASE_URL });
      await db.query('DELETE FROM device_tokens WHERE push_token = $1', [token]);
    }
    throw new Error(`FCM error: ${res.status}`);
  }
}

async function getGoogleAccessToken(): Promise<string> { return ''; /* OAuth2 flow */ }

function getCurrentHourInTimezone(tz: string): number {
  return parseInt(new Date().toLocaleString('en-US', { timeZone: tz, hour: 'numeric', hour12: false }));
}

function isInQuietHours(hour: number, start: number, end: number): boolean {
  if (start < end) return hour >= start && hour < end;
  return hour >= start || hour < end;
}

function msUntilHour(tz: string, targetHour: number): number {
  return targetHour * 3600 * 1000; // simplified
}

Step 3: Analytics API

typescript
// src/api/push-analytics.ts
import { Hono } from 'hono';
import { Redis } from 'ioredis';

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

app.get('/v1/push/analytics', async (c) => {
  const days = parseInt(c.req.query('days') ?? '7');
  const stats = [];

  for (let i = 0; i < days; i++) {
    const date = new Date(Date.now() - i * 86400000).toISOString().split('T')[0];
    const data = await redis.hgetall(`push:stats:${date}`);
    stats.push({
      date,
      delivered: parseInt(data.delivered ?? '0'),
      opened: parseInt(data.opened ?? '0'),
      dismissed: parseInt(data.dismissed ?? '0'),
    });
  }

  const totalDelivered = stats.reduce((s, d) => s + d.delivered, 0);
  const totalOpened = stats.reduce((s, d) => s + d.opened, 0);

  return c.json({
    stats,
    summary: {
      totalDelivered,
      totalOpened,
      openRate: totalDelivered > 0 ? (totalOpened / totalDelivered * 100).toFixed(1) + '%' : '0%',
    },
  });
});

export default app;

Results

  • 5M notifications/day: delivered reliably across iOS and Android
  • Open rate: 18% (was 6% with blast-everyone approach)
  • Quiet hours: zero 3 AM pings — respects every user's timezone
  • Uninstall rate: dropped 20% after switching from broadcasts to targeted
  • Category opt-out: users control what they receive, reducing frustration
  • Invalid tokens: auto-cleaned, reducing failed deliveries from 15% to 2%
  • Engagement: +35% daily active users from behavior-triggered notifications

Related use cases