TinyKit Pro Docs
Technical

Development Patterns

This document contains detailed development patterns, coding standards, and implementation examples for TinyKit Pro.

This document contains detailed development patterns, coding standards, and implementation examples for TinyKit Pro.

Code Style & Conventions

File Extensions & Organization

  • React Components: Use .tsx extension
  • Convex Functions: Use .ts extension (queries, mutations, actions)
  • Email Templates: Use .tsx extension with React Email components
  • Follow Existing Patterns: Always check neighboring files for consistency

Admin Function Pattern

All admin functions follow the *Admin suffix pattern:

// Examples:
(getAllNotificationsAdmin,
  sendNotificationToAllUsersAdmin,
  searchUsersAdmin,
  searchOrgsAdmin);

Import Conventions

// Path aliases (configured in tsconfig.json)
import { Button } from "@/components/ui/button";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";

// Convex function imports
import { query, mutation, action } from "./_generated/server";
import { v } from "convex/values";

// Modular access control imports
import { useAccess } from "@/hooks/useAccess";
import {
  requireAccess,
  hasAccess,
  getUserRolePermissions,
} from "../../lib/access"; // Backend
import { convertRoleToDisplayName } from "@/lib/formatters";

Semantic Color System

Always use semantic colors instead of hardcoded Tailwind colors:

// Good: Semantic colors that adapt to themes
className = "bg-primary text-primary-foreground";
className = "bg-card border-border text-card-foreground";

// Bad: Hardcoded Tailwind colors
className = "bg-blue-500 text-white";
className = "bg-gray-100 border-gray-200 text-gray-900";

Component Patterns

  • UI Components: Use shadcn/ui from @/components/ui/
  • Shared Components: Use reusable building blocks from @/features/shared/
  • Feature Components: Organize by domain in @/features/[domain]/
  • Form Components: Combine React Hook Form + Zod + shadcn/ui from @/features/shared/forms/
  • Navigation Components: Co-locate with routes in _nav directories
  • Permission Wrapping: Use helper functions for conditional rendering
  • Email Components: Use React Email with semantic color integration

Naming Conventions

  • Files: kebab-case (org-settings.tsx, notification-history.tsx)
  • Feature Directories: kebab-case (access-demo, site-branding, admin-dashboard)
  • Navigation Directories: _nav prefix (e.g., src/app/admin/_nav/)
  • Components: PascalCase (OrgSettings, NotificationHistory, AdminSidebar)
  • Functions: camelCase (getUserOrgRole, sendNotificationToAllUsers)
  • Constants: UPPER_SNAKE_CASE (STRIPE_PLANS, NOTIFICATION_TYPES)
  • Types: PascalCase (OrgMember, NotificationType)

ESLint Enforcement: File and folder naming is enforced via eslint-plugin-check-file:

  • src/**/*.{ts,tsx} - KEBAB_CASE (except src/app/ which uses Next.js App Router conventions)
  • emails/**/*.{ts,tsx} - KEBAB_CASE
  • scripts/**/*.ts - KEBAB_CASE
  • src/app/ folders excluded from enforcement (supports route groups (), parallel routes @, intercepting routes (.))

Run bun lint to check for naming violations.

Form Handling Patterns

Standard Form Architecture

// 1. Zod schema for validation
const createOrgSchema = z.object({
  name: z.string().min(1, "Organization name is required"),
  description: z.string().optional(),
});
type CreateOrgForm = z.infer<typeof createOrgSchema>;

// 2. React Hook Form setup
const form = useForm<CreateOrgForm>({
  resolver: zodResolver(createOrgSchema),
  defaultValues: { name: "", description: "" },
});

// 3. Convex mutation with server-side validation
export const createOrg = mutation({
  args: {
    name: v.string(),
    description: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    // Permission checks first using unified access control
    const { userId } = await requireAccess(ctx, {
      userRole: "user",
    });

    // Business logic
    const orgId = await ctx.db.insert("orgs", {
      name: args.name,
      description: args.description,
      slug: generateSlug(args.name),
      createdAt: Date.now(),
    });

    return orgId;
  },
});

Three-Tier Function Development Patterns

// PUBLIC FUNCTIONS (convex/module/public/)
// No authentication required, minimal functions for security
export const getWaitlistSettings = query({
  args: {},
  handler: async (ctx) => {
    // No authentication check needed
    const settings = await ctx.db.query("waitlistSettings").first();
    return {
      waitlistEnabled: settings?.waitlistEnabled ?? true,
      showWaitlistCount: settings?.showWaitlistCount ?? false,
    };
  },
});

// PRIVATE FUNCTIONS (convex/module/private/)
// Always require authentication, all user-facing features
export const getUserProfile = query({
  args: {},
  handler: async (ctx) => {
    // Always check authentication using modular access control
    const { userId, user } = await requireAccess(ctx, {
      userRole: ["user"], // Basic authentication required
    });

    // Pre-calculate permission arrays for frontend
    const userPermissions = getUserRolePermissions(user.userRole || "user");
    const orgPermissions: string[] = []; // Will be populated with current org context

    return {
      ...user,
      permissions: userPermissions,
      orgPermissions,
    };
  },
});

// INTERNAL FUNCTIONS (convex/module/internal/)
// Backend-only, called via ctx.runQuery/ctx.runMutation/ctx.runAction
export const sendNotificationEmail = action({
  args: { email: v.string(), subject: v.string(), content: v.string() },
  handler: async (ctx, args) => {
    // Internal function - no direct frontend access
    // Called from other Convex functions like:
    // await ctx.runAction(internal.notifications.internal.actions.sendNotificationEmail, {...});

    return await sendEmailViaResend(args);
  },
});

OAuth Image Display Patterns

Location: convex/users/helpers.ts

Use the getUserPictureUrl helper for consistent user picture handling across all queries:

import { getUserPictureUrl } from "../helpers";

export const getUser = query({
  args: { userId: v.string() }, // Better Auth uses string IDs
  handler: async (ctx, { userId }) => {
    const user = await getUserById(ctx, userId); // Use helper for Better Auth users
    if (!user) return null;

    // Handles both uploaded and OAuth images automatically
    const pictureUrl = await getUserPictureUrl(user, ctx);

    return {
      ...user,
      pictureUrl,
    };
  },
});

Image Priority System:

  1. User-uploaded images (pictureStorageId) - User uploads take priority
  2. OAuth provider images (image field) - GitHub, Google, Apple profile pictures
  3. Default state (null) - No image available

Email Template Development

Directory Organization: All email templates are organized into logical subdirectories under /emails/:

emails/
├── auth/                 # Authentication emails (verification, password reset)
├── notifications/        # System notifications and announcements
├── billing/             # Subscription and payment emails
├── organization/        # Team and role management emails
├── onboarding/          # Welcome and user invitation emails
└── common/              # Shared layout components
    ├── Layout.tsx       # Main email layout with theming
    ├── Header.tsx       # Email header component
    └── Footer.tsx       # Email footer component

Database-Driven Theming: All templates use unified email configuration for consistent branding:

// In Convex auth providers - unified query for performance
const emailConfig = await ctx.runQuery(
  internal.siteSettings.internal.queries.getEmailConfig,
);

// Pass to email template
react: VerificationCodeEmail({
  code: token,
  expires: new Date(Date.now() + 20 * 60 * 1000),
  themeColors: emailConfig.lightThemeColors, // Database theme colors
  siteName: emailConfig.siteName, // Dynamic site name
});

Semantic Color Requirements: Always use semantic theme variables instead of hardcoded Tailwind colors:

// ✅ Good: Semantic colors that adapt to themes
<Section className="bg-primary/10 border-2 border-primary rounded-md">
  <Text className="text-primary font-bold">{title}</Text>
  <Text className="text-muted-foreground">{message}</Text>
</Section>

// ❌ Bad: Hardcoded Tailwind colors
<Section className="bg-blue-50 border-2 border-blue-500 rounded-md">
  <Text className="text-blue-800 font-bold">{title}</Text>
  <Text className="text-gray-600">{message}</Text>
</Section>

Access Control Patterns

Modular Access Control Architecture

Backend Modular Structure (convex/lib/access/):

// Modular backend imports
import {
  requireAccess,
  hasAccess,
  getUserRolePermissions,
} from "../../lib/access";

// Individual modules:
// - requireAccess.ts: requireAccess() and hasAccess() functions
// - requireAccessForAction.ts: Action-specific access control
// - statements.ts: Better Auth role configuration
// - orgPermissions.ts: Organization role hierarchy
// - types.ts: Type definitions (HasAccessOptions, AccessContext)
// - index.ts: Main exports

Frontend Access Control:

// Frontend - role-based access control
import { useAccess } from "@/hooks/use-access";

const { hasAccess } = useAccess();

// Role-based checking
const isAdmin = hasAccess({ userRole: "admin" });
const canAccessAdmin = hasAccess({ userRole: ["admin"] });

// Organization-specific access
const canEditOrg = hasAccess({ orgId: org._id, orgRole: "owner" });

// Performance-optimized permission checking
const canDeleteUsers = userData.permissions?.includes("users:delete") ?? false;
const canManageOrgs = userData.orgPermissions?.includes("orgs:update") ?? false;

// Conditional rendering with permission arrays
{userData.permissions?.includes("admin:access") && <AdminPanel />}
{canEditOrg && <OrgSettings />}

Organization Management Patterns

// Leave organization with owner restrictions
export const leaveOrg = mutation({
  args: { orgId: v.string() }, // Better Auth uses string IDs
  handler: async (ctx, { orgId }) => {
    const { userId } = await requireAccess(ctx, {});
    const membership = await ctx.db
      .query("orgMembers")
      .withIndex("by_user_and_org", (q) =>
        q.eq("userId", userId).eq("orgId", orgId),
      )
      .first();

    if (!membership) {
      throwConvexError("NOT_MEMBER_OF_ORG");
    }

    // Prevent owners from leaving
    if (membership.orgRole === "owner") {
      throw new Error(
        "Organization owners cannot leave. Please transfer ownership or delete the organization instead.",
      );
    }

    await ctx.db.delete(membership._id);
    return { success: true };
  },
});

Storage Management Patterns

Convex Storage Cleanup Pattern

// Pattern: Always clean up old files when updating storage references
export const updateStorageReference = mutation({
  args: { newStorageId: v.id("_storage") },
  handler: async (ctx, { newStorageId }) => {
    const { userId } = await requireAccess(ctx, { userRole: ["admin"] });

    // Get existing settings/record
    const existing = await ctx.db.query("tableName").first();

    // Delete old storage file if it exists
    const oldStorageId = existing?.storageId;
    if (oldStorageId) {
      try {
        await ctx.storage.delete(oldStorageId);
      } catch (error) {
        // Log but don't fail - old file might already be deleted
        logger.warn("Failed to delete old storage file:", error);
      }
    }

    // Update with new storage ID
    await ctx.db.patch(existing._id, {
      storageId: newStorageId,
      updatedBy: userId,
      updatedAt: Date.now(),
    });
  },
});

Billing System Development

  1. Simplified Product Naming: Products use a single name field for both Stripe and UI display

    • No displayName field: Removed from database schema, Stripe configuration, and frontend components
    • Unified naming: Single source of truth for product names across the entire system
    • Cleaner data model: Reduces complexity in product management and sync operations
  2. Use helper functions from convex/billing/helpers.ts for subscription checks:

    • isSubscriptionActive() - Enhanced with expiration checking
    • hasSubscriptionExpired() - Check if subscription period has ended
    • getOrgAccessLevel() - Get organization's current access tier
    • getPersonalAccessLevel() - Get user's current access tier
  3. Always check subscription status AND expiration date together

  4. Handle graceful cancellation periods (access retained until currentPeriodEnd)

  5. Use real-time access checks rather than relying solely on webhooks

  6. Follow established access level patterns (0=free, 1=professional, 2=enterprise)

React Key Collision Resolution: Use unique identifiers for dynamic lists:

// Best Practice: Use unique document IDs as keys
key={product._id}

// For composite scenarios: Combine multiple unique fields
key={`${plan.name}-${plan.type}`}

// Admin Filter Pattern: Use product IDs as filter values
// Instead of filtering by product name (which can duplicate across types),
// filter by product ID for guaranteed uniqueness
const planOptions = products.map(product => ({
  value: product._id,  // Unique ID as value
  label: product.name, // Display name as label
}));

// Prevents React key collisions when products share names across types
// (e.g., "Free" plan exists for both "personal" and "org" types)

Why Use IDs Over Names for Filters:

  • Document IDs are always unique (React never throws duplicate key warnings)
  • More maintainable (works even if product names change)
  • Type-safe with Id<"products"> in TypeScript
  • Better performance (database lookups by ID are O(1))

Rate Limiting Patterns

IMPORTANT: Always use requireRateLimit() to protect mutations from abuse and prevent excessive resource usage.

Standard Rate Limiting Pattern

import { requireAccess, requireRateLimit } from "../../lib/access";

export const updateProfile = mutation({
  args: { name: v.string(), bio: v.optional(v.string()) },
  handler: async (ctx, args) => {
    // 1. Access control first
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });

    // 2. Rate limit based on userId (5 updates/min with burst capacity of 10)
    await requireRateLimit(ctx, "profileUpdates", { key: userId });

    // 3. Business logic
    await ctx.db.patch(userId, {
      name: args.name,
      bio: args.bio,
    });

    return { success: true };
  },
});

Pre-configured Rate Limits

  • authAttempts - 10/hour (fixed window) - Sign in, sign up, password reset
  • profileUpdates - 5/min, burst 10 (token bucket) - Profile changes
  • contentCreation - 20/min, burst 30 (token bucket) - Posts, comments, messages
  • adminOperations - 100/hour, burst 150 (token bucket) - Admin actions
  • externalApiCalls - 50/hour, burst 100 (token bucket) - Third-party APIs
  • fileUploads - 10/hour, burst 20 (token bucket) - File storage
  • searchQueries - 30/min, burst 50 (token bucket) - Search operations

Advanced Rate Limiting Options

// Custom token consumption (e.g., LLM API with variable cost)
export const processAIRequest = action({
  args: { prompt: v.string(), estimatedTokens: v.number() },
  handler: async (ctx, args) => {
    const { userId } = await requireAccessForAction(ctx, {
      userRole: ["user"],
    });

    // Consume multiple tokens based on request size
    await requireRateLimit(ctx, "externalApiCalls", {
      key: userId,
      count: args.estimatedTokens,
    });

    // Process AI request
    return await callExternalAI(args.prompt);
  },
});

// Check limit without throwing
export const checkUploadAvailability = query({
  args: {},
  handler: async (ctx) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });

    // Check if user can upload without throwing error
    const { ok, retryAfter } = await requireRateLimit(ctx, "fileUploads", {
      key: userId,
      throws: false, // Don't throw on limit exceeded
    });

    return {
      canUpload: ok,
      retryAfter: retryAfter ?? null,
    };
  },
});

When to Use Rate Limiting

✅ Always use for:

  • User-facing mutations (profile updates, content creation)
  • Authentication operations (sign in, sign up, password reset)
  • File uploads and expensive operations
  • External API calls (third-party integrations)
  • Admin operations (to prevent accidental abuse)

❌ Don't use for:

  • Internal mutations (already protected by function visibility)
  • Simple queries (unless computationally expensive)
  • Read-only operations with no side effects

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro