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

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

Skills stack · 5 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
$

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