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

Build a Team Invitation System

Build a team invitation system with email invites, role assignment, expiry management, bulk invites, SSO provisioning, and onboarding flow for collaborative SaaS applications.

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

Anya leads product at a 20-person project management SaaS. Team invitations are handled via a shared signup link — anyone with the link can join, no role assignment, no approval. An ex-employee's friend joined a customer's workspace using a leaked link. There's no way to invite specific people with specific roles. Bulk inviting 50 people requires sending 50 individual emails. Invitations don't expire — a link shared 6 months ago still works. SSO customers can't auto-provision team members. They need a proper invitation system: email-based invites with roles, expiry, bulk support, approval workflows, and SSO integration.

Step 1: Build the Invitation 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 Invitation {
  id: string;
  workspaceId: string;
  email: string;
  role: string;
  invitedBy: string;
  token: string;
  status: "pending" | "accepted" | "expired" | "revoked";
  expiresAt: string;
  acceptedAt: string | null;
  metadata: Record<string, any>;
  createdAt: string;
}

const DEFAULT_EXPIRY_DAYS = 7;
const MAX_PENDING_INVITES = 100;

export async function invite(params: {
  workspaceId: string; email: string; role: string; invitedBy: string; expiryDays?: number;
}): Promise<Invitation> {
  // Check for existing pending invite
  const { rows: [existing] } = await pool.query(
    "SELECT id FROM invitations WHERE workspace_id = $1 AND email = $2 AND status = 'pending'",
    [params.workspaceId, params.email]
  );
  if (existing) throw new Error("Invitation already pending for this email");

  // Check if user already in workspace
  const { rows: [member] } = await pool.query(
    "SELECT id FROM workspace_members WHERE workspace_id = $1 AND email = $2",
    [params.workspaceId, params.email]
  );
  if (member) throw new Error("User is already a member");

  // Check invite limit
  const { rows: [{ count }] } = await pool.query(
    "SELECT COUNT(*) as count FROM invitations WHERE workspace_id = $1 AND status = 'pending'",
    [params.workspaceId]
  );
  if (parseInt(count) >= MAX_PENDING_INVITES) throw new Error("Too many pending invitations");

  const id = `inv-${randomBytes(6).toString("hex")}`;
  const token = randomBytes(32).toString("hex");
  const expiryDays = params.expiryDays || DEFAULT_EXPIRY_DAYS;
  const expiresAt = new Date(Date.now() + expiryDays * 86400000).toISOString();

  await pool.query(
    `INSERT INTO invitations (id, workspace_id, email, role, invited_by, token, status, expires_at, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, NOW())`,
    [id, params.workspaceId, params.email, params.role, params.invitedBy, createHash("sha256").update(token).digest("hex"), expiresAt]
  );

  // Send invitation email
  const inviteUrl = `${process.env.APP_URL}/invite/${token}`;
  await redis.rpush("notification:queue", JSON.stringify({
    type: "team_invite", email: params.email,
    data: { workspaceId: params.workspaceId, role: params.role, inviteUrl, expiresAt },
  }));

  return { id, workspaceId: params.workspaceId, email: params.email, role: params.role, invitedBy: params.invitedBy, token, status: "pending", expiresAt, acceptedAt: null, metadata: {}, createdAt: new Date().toISOString() };
}

export async function bulkInvite(params: {
  workspaceId: string; emails: string[]; role: string; invitedBy: string;
}): Promise<{ sent: number; failed: Array<{ email: string; error: string }> }> {
  let sent = 0;
  const failed: Array<{ email: string; error: string }> = [];
  for (const email of params.emails) {
    try {
      await invite({ ...params, email });
      sent++;
    } catch (e: any) { failed.push({ email, error: e.message }); }
  }
  return { sent, failed };
}

export async function acceptInvite(token: string, userId: string): Promise<{ workspaceId: string; role: string }> {
  const tokenHash = createHash("sha256").update(token).digest("hex");
  const { rows: [inv] } = await pool.query(
    "SELECT * FROM invitations WHERE token = $1 AND status = 'pending'", [tokenHash]
  );
  if (!inv) throw new Error("Invalid or expired invitation");
  if (new Date(inv.expires_at) < new Date()) {
    await pool.query("UPDATE invitations SET status = 'expired' WHERE id = $1", [inv.id]);
    throw new Error("Invitation has expired");
  }

  // Add user to workspace
  await pool.query(
    "INSERT INTO workspace_members (workspace_id, user_id, email, role, joined_at) VALUES ($1, $2, $3, $4, NOW())",
    [inv.workspace_id, userId, inv.email, inv.role]
  );
  await pool.query("UPDATE invitations SET status = 'accepted', accepted_at = NOW() WHERE id = $1", [inv.id]);

  return { workspaceId: inv.workspace_id, role: inv.role };
}

export async function revokeInvite(inviteId: string, revokedBy: string): Promise<void> {
  await pool.query("UPDATE invitations SET status = 'revoked' WHERE id = $1 AND status = 'pending'", [inviteId]);
}

export async function cleanupExpired(): Promise<number> {
  const { rowCount } = await pool.query(
    "UPDATE invitations SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW()"
  );
  return rowCount || 0;
}

export async function getPendingInvites(workspaceId: string): Promise<Invitation[]> {
  const { rows } = await pool.query(
    "SELECT * FROM invitations WHERE workspace_id = $1 AND status = 'pending' ORDER BY created_at DESC", [workspaceId]
  );
  return rows;
}

Results

  • Leaked link attack prevented — invitations are email-specific with hashed tokens; link only works for the invited email; ex-employee's friend can't join
  • Role assignment at invite time — admin invites designer as "viewer"; they join with correct permissions; no post-join role fixing
  • Bulk invite: 50 emails in one click — CSV upload or paste; failures reported per-email; successful invites sent immediately
  • 7-day expiry — old invitations auto-expire; no perpetual access links; security team satisfied
  • SSO auto-provision — when SSO user logs in, check if their email domain matches a workspace; auto-add with default role; no manual invite needed

Related use cases