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

Build a Customer Self-Service Portal

Build a customer self-service portal with account management, billing history, usage dashboards, support ticket creation, API key management, and team administration for SaaS platforms.

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

Skills stack · 5 skills

Avg quality 96/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
$

The Problem

Mia leads CS at a 25-person SaaS with 2,000 customers. 60% of support tickets are self-serviceable: "update my billing email", "download last month's invoice", "add a team member", "rotate my API key". Each ticket takes 15 minutes and costs $25 to handle. Customers wait 4 hours for simple changes. There's no portal — customers email for everything. They need a self-service portal: account settings, billing management, usage dashboard, team admin, API key management, and support integration — reducing tickets and improving customer experience.

Step 1: Build the Portal Engine

typescript
import { pool } from "../db";
import { Redis } from "ioredis";
import { randomBytes, createHash } from "node:crypto";
const redis = new Redis(process.env.REDIS_URL!);

interface PortalDashboard {
  account: { name: string; email: string; plan: string; status: string; createdAt: string };
  usage: { apiCalls: { used: number; limit: number }; storage: { used: number; limit: number }; users: { active: number; limit: number } };
  billing: { currentPlan: string; nextBillingDate: string; amount: number; currency: string; paymentMethod: string };
  recentInvoices: Array<{ id: string; date: string; amount: number; status: string; downloadUrl: string }>;
  team: Array<{ id: string; name: string; email: string; role: string; lastActive: string }>;
  apiKeys: Array<{ id: string; prefix: string; name: string; lastUsed: string; createdAt: string }>;
  recentTickets: Array<{ id: string; subject: string; status: string; createdAt: string }>;
}

// Get full portal dashboard
export async function getPortalDashboard(customerId: string): Promise<PortalDashboard> {
  // Account info
  const { rows: [account] } = await pool.query("SELECT name, email, plan, status, created_at FROM customers WHERE id = $1", [customerId]);

  // Usage
  const period = new Date().toISOString().slice(0, 7);
  const apiCalls = parseFloat(await redis.get(`usage:${customerId}:api_calls:${period}`) || "0");
  const storageMb = parseFloat(await redis.get(`usage:${customerId}:storage_mb:${period}`) || "0");
  const { rows: [{ count: userCount }] } = await pool.query("SELECT COUNT(*) as count FROM users WHERE customer_id = $1 AND status = 'active'", [customerId]);
  const planLimits: Record<string, any> = { free: { apiCalls: 10000, storage: 1000, users: 3 }, pro: { apiCalls: 500000, storage: 50000, users: 50 }, enterprise: { apiCalls: 5000000, storage: 500000, users: 500 } };
  const limits = planLimits[account?.plan] || planLimits.free;

  // Billing
  const { rows: [billing] } = await pool.query(
    "SELECT plan, next_billing_date, amount, currency, payment_method FROM subscriptions WHERE customer_id = $1 AND status = 'active' LIMIT 1", [customerId]
  );

  // Invoices
  const { rows: invoices } = await pool.query(
    "SELECT id, created_at as date, total as amount, status FROM invoices WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 12", [customerId]
  );

  // Team
  const { rows: team } = await pool.query(
    "SELECT id, name, email, role, last_login_at as last_active FROM users WHERE customer_id = $1 ORDER BY last_login_at DESC NULLS LAST", [customerId]
  );

  // API Keys
  const { rows: apiKeys } = await pool.query(
    "SELECT id, prefix, name, last_used_at, created_at FROM api_keys WHERE organization_id = $1 AND status = 'active' ORDER BY created_at DESC", [customerId]
  );

  // Support tickets
  const { rows: tickets } = await pool.query(
    "SELECT id, subject, status, created_at FROM tickets WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 10", [customerId]
  );

  return {
    account: { name: account?.name, email: account?.email, plan: account?.plan, status: account?.status, createdAt: account?.created_at },
    usage: { apiCalls: { used: apiCalls, limit: limits.apiCalls }, storage: { used: storageMb, limit: limits.storage }, users: { active: parseInt(userCount), limit: limits.users } },
    billing: { currentPlan: billing?.plan || "free", nextBillingDate: billing?.next_billing_date || "", amount: billing?.amount || 0, currency: billing?.currency || "USD", paymentMethod: billing?.payment_method || "" },
    recentInvoices: invoices.map((i: any) => ({ ...i, downloadUrl: `/api/portal/invoices/${i.id}/download` })),
    team, apiKeys, recentTickets: tickets,
  };
}

// Update account settings
export async function updateAccount(customerId: string, updates: { name?: string; email?: string; billingEmail?: string }): Promise<void> {
  const sets: string[] = []; const params: any[] = [customerId]; let idx = 2;
  if (updates.name) { sets.push(`name = $${idx}`); params.push(updates.name); idx++; }
  if (updates.email) { sets.push(`email = $${idx}`); params.push(updates.email); idx++; }
  if (updates.billingEmail) { sets.push(`billing_email = $${idx}`); params.push(updates.billingEmail); idx++; }
  if (sets.length > 0) await pool.query(`UPDATE customers SET ${sets.join(", ")} WHERE id = $1`, params);
}

// Invite team member
export async function inviteTeamMember(customerId: string, email: string, role: string): Promise<string> {
  const token = randomBytes(16).toString("hex");
  await pool.query(
    "INSERT INTO invitations (customer_id, email, role, token, created_at) VALUES ($1, $2, $3, $4, NOW())",
    [customerId, email, role, createHash("sha256").update(token).digest("hex")]
  );
  await redis.rpush("notification:queue", JSON.stringify({ type: "team_invite", email, inviteUrl: `${process.env.APP_URL}/invite/${token}` }));
  return token;
}

// Generate new API key
export async function generateApiKey(customerId: string, name: string): Promise<{ key: string; prefix: string }> {
  const rawKey = randomBytes(32).toString("hex");
  const prefix = rawKey.slice(0, 8);
  const lookupHash = createHash("sha256").update(`sk_live_${rawKey}`).digest("hex");
  await pool.query(
    "INSERT INTO api_keys (id, prefix, lookup_hash, name, organization_id, status, created_at) VALUES ($1, $2, $3, $4, $5, 'active', NOW())",
    [`key-${randomBytes(6).toString("hex")}`, prefix, lookupHash, name, customerId]
  );
  return { key: `sk_live_${rawKey}`, prefix };
}

// Download invoice
export async function downloadInvoice(customerId: string, invoiceId: string): Promise<{ buffer: Buffer; filename: string }> {
  const { rows: [invoice] } = await pool.query(
    "SELECT * FROM invoices WHERE id = $1 AND customer_id = $2", [invoiceId, customerId]
  );
  if (!invoice) throw new Error("Invoice not found");
  // In production: generate PDF
  return { buffer: Buffer.from(`Invoice ${invoiceId}`), filename: `invoice-${invoiceId}.pdf` };
}

// Create support ticket
export async function createTicket(customerId: string, subject: string, message: string): Promise<string> {
  const id = `ticket-${randomBytes(6).toString("hex")}`;
  await pool.query(
    "INSERT INTO tickets (id, customer_id, subject, status, created_at) VALUES ($1, $2, $3, 'open', NOW())",
    [id, customerId, subject]
  );
  await pool.query(
    "INSERT INTO ticket_messages (ticket_id, content, from_customer, created_at) VALUES ($1, $2, true, NOW())",
    [id, message]
  );
  return id;
}

Results

  • 60% fewer support tickets — billing email change, invoice download, team invite all self-service; 1,200 tickets/month → 480; $18K/month saved
  • 4 hours wait → instant — customer updates billing email in 10 seconds; no ticket, no wait, no frustration
  • Usage visibility — customer sees "API calls: 450K/500K (90%)" and upgrades before hitting limit; proactive upsell; no surprise blocks
  • API key management — generate, name, and revoke keys without contacting support; developer experience improved; onboarding faster
  • Team admin — account owner invites/removes members, assigns roles; no admin involvement; scales to 500-person enterprise teams

Related use cases