TinyKit Pro Docs
Technical

API Reference

This document provides comprehensive reference for TinyKit Pro's API patterns, function conventions, and development standards.

This document provides comprehensive reference for TinyKit Pro's API patterns, function conventions, and development standards.

API Design Patterns

Function Organization

TinyKit Pro follows a consistent domain-based organization pattern:

convex/
├── domain/
│   ├── public/              # Public API functions
│   │   ├── queries.ts       # Read operations (SELECT)
│   │   ├── mutations.ts     # Write operations (INSERT, UPDATE, DELETE)
│   │   └── actions.ts       # External API calls (Stripe, Resend, etc.)
│   ├── internal/            # Internal helper functions
│   ├── helpers.ts           # Domain utilities and permission checks
│   └── schema.ts           # Domain type definitions

Function Naming Conventions

Admin Functions (Suffix Pattern)

All admin functions follow the *Admin suffix pattern for clear identification:

// Queries
getAllNotificationsAdmin; // Get paginated notification history
getNotificationStatsAdmin; // System-wide statistics
searchUsersAdmin; // User search for admin interface
searchTeamsAdmin; // Team search for targeting

// Mutations
sendNotificationToAllUsersAdmin; // Broadcast to all users
sendNotificationToAllTeamOwnersAdmin; // Broadcast to team owners
bulkMarkAsReadAdmin; // Bulk operations
createTeamAdmin; // Admin-created teams

Permission-Based Function Names

// Role requirements implied by naming
requireAdmin*              // Admin only functions
requireTeamOwner*          // Team owner functions
requireTeamAdmin*          // Team admin/owner functions

Type Safety Patterns

Convex Validators

Always use Convex validators for runtime type checking:

import { v } from "convex/values";

export const createTeam = mutation({
  args: {
    name: v.string(),
    description: v.optional(v.string()),
    initialMembers: v.array(v.string()),
  },
  handler: async (ctx, args) => {
    // Runtime validation ensures args match exactly
  },
});

Generated Types

Use Convex generated types for complete type safety:

import type { Id } from "@/convex/_generated/dataModel";
import { api } from "@/convex/_generated/api";

// Use generated ID types
const teamId: Id<"teams"> = "team123" as Id<"teams">;

// Use generated API for function calls
const team = useQuery(api.teams.queries.getById, { id: teamId });

Authentication & Authorization

Core Access Control Functions

Location: convex/lib/access/ (modular policy-based access control with 7 specialized files)

// Import from modular access control system
import {
  requireAccess,
  hasAccess,
  requireAccessForAction,
  getUserRolePermissions,
  getOrgRolePermissions,
} from "@/convex/lib/access";

// Primary access control function - enforces requirements (requireAccess.ts)
export async function requireAccess(
  ctx: QueryCtx | MutationCtx,
  options: HasAccessOptions,
): Promise<AccessContext>;

// Conditional access checking - returns boolean (requireAccess.ts)
export async function hasAccess(
  ctx: QueryCtx | MutationCtx,
  options: HasAccessOptions,
): Promise<boolean>;

// Action-specific access control (requireAccessForAction.ts)
export async function requireAccessForAction(
  ctx: ActionCtx,
  options: HasAccessOptions,
): Promise<boolean>;

// Permission array generation utilities (utils.ts)
export function getUserRolePermissions(userRole: string | null): string[];
export function getOrgRolePermissions(orgRole: string | null): string[];

// Access options interface (types.ts) - supports arrays for multiple allowed roles
interface HasAccessOptions {
  userRole?: "admin" | "user" | Array<"admin" | "user">;
  orgRole?: "owner" | "admin" | "member" | Array<"owner" | "admin" | "member">;
  permission?: string; // "resource:action" format
  minPersonalAccessLevel?: number; // 0-3 (database-driven from products table)
  minOrgAccessLevel?: number; // 0-3 (database-driven from products table)
  orgId?: Id<"orgs">;
  condition?: (ctx: AccessContext) => boolean | Promise<boolean>;
}

// Access context with guaranteed non-null values (types.ts)
interface AccessContext {
  userId: Id<"users">; // Guaranteed non-null
  user: Doc<"users">; // Guaranteed non-null
  userRole: string; // User's role
  orgRole: string | null; // Org role if orgId provided
  personalAccessLevel: number; // Personal access level (0-3, database-driven)
  orgAccessLevel: number | null; // Org access level if orgId provided (database-driven)
}

Permission Array System

Location: Backend generates permission arrays, frontend uses for performance optimization

// Backend: Generate permission arrays in user queries
import {
  getUserRolePermissions,
  getOrgRolePermissions,
} from "@/convex/lib/access";

export const me = query({
  args: {},
  handler: async (ctx) => {
    const { userId, user, orgRole } = await requireAccess(ctx, {
      userRole: ["user"],
    });

    // Generate permission arrays for frontend
    const userPermissions = getUserRolePermissions(user.userRole || "user");
    const orgPermissions = getOrgRolePermissions(orgRole);

    return {
      ...user,
      permissions: userPermissions, // ["users:read", "users:update", ...]
      orgPermissions: orgPermissions, // ["orgMembers:invite", "messages:create", ...]
    };
  },
});

// Frontend: Fast permission checking using .includes()
const userData = useQuery(api.users.private.queries.getMe);

// Direct permission array checking (fastest)
const canDeleteUsers = userData?.permissions?.includes("users:delete") ?? false;
const canInviteMembers =
  userData?.orgPermissions?.includes("orgMembers:invite") ?? false;

Modular Architecture Files

Organization: Six specialized files in convex/lib/access/

  1. index.ts - Main exports (requireAccess, hasAccess, etc.)
  2. types.ts - Type definitions (HasAccessOptions, AccessContext)
  3. requireAccess.ts - requireAccess() (throws) and hasAccess() (returns boolean)
  4. requireAccessForAction.ts - Action-specific access control
  5. statements.ts - Better Auth role configuration
  6. orgPermissions.ts - Organization role hierarchy

Simplified Permission System

TinyKit Pro uses a simplified permission model for performance:

// User role permissions - simplified to ["*"] or []
export function getUserRolePermissions(role: string): string[] {
  return role === "admin" ? ["*"] : [];
}

// Team role permissions
export const teamRolePermissions = {
  owner: { all: true },
  admin: {
    team: ["read", "update"],
    members: ["read", "invite", "remove"],
    content: ["create", "read", "update", "delete"],
  },
  member: {
    team: ["read"],
    content: ["create", "read"],
  },
} as const;

// Database-driven access levels (stored in products table)
// Access levels are no longer hardcoded but retrieved from products.accessLevel field
// This provides a single source of truth for subscription-based access control
export const accessLevels = {
  0: "Free", // Basic features
  1: "Basic", // Essential premium features
  2: "Pro", // Advanced features
  3: "Enterprise", // All features
} as const;

Access Control Implementation Patterns

Always use the policy-based access control system for protection:

export const protectedMutation = mutation({
  args: {
    teamId: v.id("teams"),
    data: v.object({}),
  },
  handler: async (ctx, args) => {
    // 1. Enforce access requirements FIRST (using modular system)
    const { userId, teamRole } = await requireAccess(ctx, {
      teamId: args.teamId,
      teamRole: "owner",
    });

    // 2. Guaranteed access - proceed with business logic
    const team = await ctx.db.get(args.teamId);
    if (!team) {
      throw new ConvexError("Team not found");
    }

    // 3. Execute operation with context
    return await ctx.db.patch(args.teamId, {
      ...args.data,
      updatedBy: userId,
      updatedAt: Date.now(),
    });
  },
});

// Conditional access example
export const getTeamData = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    // Check access without throwing
    const canAccess = await hasAccess(ctx, {
      teamId: args.teamId,
      teamRole: "member",
    });

    if (!canAccess) {
      return { error: "Team access required" };
    }

    return await ctx.db.get(args.teamId);
  },
});

// Complex access control example
export const upgradeTeamPlan = mutation({
  args: { teamId: v.id("teams"), planId: v.string() },
  handler: async (ctx, args) => {
    // Multiple access requirements in single call
    const { userId, orgRole } = await requireAccess(ctx, {
      orgId: args.orgId,
      orgRole: "owner", // Must be org owner
      minOrgAccessLevel: 1, // Must have paid subscription
    });

    // All requirements verified - proceed
    return await upgradeSubscription(args.teamId, args.planId);
  },
});

Utility Helper Functions

Billing & Subscription Helpers

The billing system provides essential helper functions for subscription management and access control:

Subscription Status Checking

// Enhanced subscription activity check with expiration handling
export function isSubscriptionActive(
  subscription: Doc<"subscriptions">,
): boolean {
  const isStatusActive =
    subscription.status === "active" || subscription.status === "trialing";

  // If status is not active, subscription is definitely not active
  if (!isStatusActive) {
    return false;
  }

  // If subscription is canceled at period end and has expired, it's not active
  if (subscription.cancelAtPeriodEnd && hasExpired(subscription)) {
    return false;
  }

  return true;
}

Policy-Based Access Control System

Function Names and Purpose

Clear, Descriptive Function Names:

  • requireAccess() - Enforces access requirements, throws on failure
  • requireAccessForAction() - Enforces access in Convex actions
  • Frontend hasAccess() - Checks access conditions, returns boolean

Core Access Control Functions

requireAccess() - Enforcement Function

Primary function for enforcing access control with guaranteed non-null results:

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

export const deleteUser = mutation({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    // Enforces access - throws descriptive error if denied
    const { userId: adminId } = await requireAccess(ctx, {
      userRole: "admin",
    });

    // Guaranteed access - proceed with operation
    await ctx.db.delete(args.userId);
    return { success: true };
  },
});

// Complex access control example
const { userId, orgRole, personalAccessLevel } = await requireAccess(ctx, {
  userRole: "user", // Must be authenticated
  orgId: args.orgId, // Organization context
  orgRole: "owner", // Must be org owner
  minPersonalAccessLevel: 1, // Must have premium subscription
  permission: "orgs:billing", // Must have billing permission
});

hasAccess() - Conditional Function

Returns boolean for conditional logic without throwing:

import { hasAccess } from "../../lib/access";

export const getAdminStats = query({
  args: {},
  handler: async (ctx) => {
    // Check access without throwing
    const isAdmin = await hasAccess(ctx, {
      userRole: "admin",
    });

    if (!isAdmin) {
      return { message: "Admin access required" };
    }

    return await generateAdminStatistics(ctx);
  },
});

// Multiple condition checks
const canManageOrg = await hasAccess(ctx, {
  orgId: args.orgId,
  orgRole: "owner",
});

const hasAdvancedFeatures = await hasAccess(ctx, {
  minPersonalAccessLevel: 1, // Premium subscription
});

requireAccessForAction() - Action Function

Specialized for Convex actions using internal queries:

import { requireAccessForAction } from "../../lib/access";

export const sendNotificationEmail = action({
  args: { userId: v.string(), message: v.string() },
  handler: async (ctx, args) => {
    // Actions require special handling
    const hasAccess = await requireAccessForAction(ctx, {
      permission: "notifications:send",
    });

    if (!hasAccess) {
      throw new Error("Insufficient permissions");
    }

    // Send email via Resend
    return await sendEmailViaResend(args.message);
  },
});

Migration from Old Function Names

Before (unclear naming):

// Old names were ambiguous about function behavior
await protect(ctx, { userRoles: ["admin"] }); // What does "protect" mean?
const access = await hasAccessForAction(ctx, options); // Inconsistent naming

After (clear, descriptive naming):

// New names clearly indicate function purpose
const { userId } = await requireAccess(ctx, {
  // Clearly requires access
  userRole: "admin",
});

const hasPermission = await requireAccessForAction(ctx, {
  // Consistent naming
  permission: "users:delete",
});

const canAccess = await hasAccess(ctx, {
  // Returns boolean
  orgId: org._id,
  orgRole: "owner",
});

Usage Patterns

// Check if organization has access to a feature
const { orgAccessLevel } = await requireAccess(ctx, {
  orgId: args.orgId,
  minOrgAccessLevel: 2, // Enterprise tier
});

// Verify subscription is still active before allowing operation
const subscription = await ctx.db
  .query("subscriptions")
  .withIndex("by_org", (q) => q.eq("orgId", args.orgId))
  .first();

if (!subscription || !isSubscriptionActive(subscription)) {
  throw new ConvexError("Active subscription required");
}

Key Benefits

  • Real-time Expiration: No waiting for webhooks to revoke access
  • Graceful Cancellation: Users retain access until their paid period ends
  • Backward Compatibility: Existing code automatically benefits from enhanced checks
  • Consistent Logic: Same helper functions used across team and personal subscriptions

Rate Limiting API

requireRateLimit()

Protects mutations from abuse and prevents excessive resource usage.

Import:

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

Function Signature:

async function requireRateLimit(
  ctx: MutationCtx | QueryCtx | ActionCtx,
  limitType: string,
  options: {
    key: string | Id<TableNames>;
    count?: number;
    throws?: boolean;
  },
): Promise<{ ok: boolean; retryAfter?: number }>;

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

Usage Examples:

// Standard rate limiting
export const updateProfile = mutation({
  args: { name: v.string() },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    await requireRateLimit(ctx, "profileUpdates", { key: userId });
    await ctx.db.patch(userId, { name: args.name });
  },
});

// Custom token consumption
export const processLargeRequest = mutation({
  args: { data: v.string(), estimatedCost: v.number() },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    await requireRateLimit(ctx, "externalApiCalls", {
      key: userId,
      count: args.estimatedCost, // Consume multiple tokens
    });
    // Process request
  },
});

// Non-throwing check
export const checkUploadAvailability = query({
  args: {},
  handler: async (ctx) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    const { ok, retryAfter } = await requireRateLimit(ctx, "fileUploads", {
      key: userId,
      throws: false,
    });
    return { canUpload: ok, retryAfter };
  },
});

When to Use:

  • ✅ User-facing mutations (profile updates, content creation)
  • ✅ Authentication operations (sign in, sign up, password reset)
  • ✅ File uploads and expensive operations
  • ✅ External API calls
  • ✅ Admin operations (prevent accidental abuse)
  • ❌ Internal mutations (already protected)
  • ❌ Simple queries (unless computationally expensive)

Database Query Patterns

Efficient Query Design

Use Indexes for Performance

// Good: Use indexes for efficient queries
export const getTeamMessages = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("messages")
      .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
      .order("desc")
      .take(50);
  },
});

// Bad: Full table scan
export const getTeamMessagesSlow = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    const allMessages = await ctx.db.query("messages").collect();
    return allMessages.filter((m) => m.teamId === args.teamId);
  },
});

Pagination Patterns

// Cursor-based pagination for performance
export const getPaginatedMessages = query({
  args: {
    teamId: v.id("teams"),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("messages")
      .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
      .order("desc")
      .paginate(args.paginationOpts);
  },
});

Database Schema Patterns

Relationship Design

// One-to-many: Team to Members
teams: {
  _id: Id<"teams">,
  name: string,
  slug: string,
  // ... other fields
}

teamMembers: {
  _id: Id<"teamMembers">,
  teamId: Id<"teams">,        // Foreign key to teams
  userId: Id<"users">,        // Foreign key to users
  teamRole: "owner" | "admin" | "member",
  joinedAt: number
}

// Indexes for efficient queries
teamMembers.byTeam: ["teamId"]
teamMembers.byUser: ["userId"]
teamMembers.byTeamAndUser: ["teamId", "userId"]

Error Handling Patterns

Consistent Error Responses

export const safeMutation = mutation({
  args: { data: v.object({}) },
  handler: async (ctx, args) => {
    try {
      // Validate permissions using policy-based access control
      const { userId } = await requireAccess(ctx, {
        role: "admin",
      });

      // Execute operation
      const result = await performOperation(ctx, args.data);

      return { success: true, data: result };
    } catch (error) {
      // Log for debugging
      console.error("Operation failed:", error);

      // Return user-friendly error
      throw new ConvexError(
        error instanceof ConvexError
          ? error.message
          : "Operation failed. Please try again.",
      );
    }
  },
});

Frontend Error Handling

const MyComponent = () => {
  const mutation = useMutation(api.domain.mutations.safeMutation);

  const handleAction = async (data) => {
    try {
      const result = await mutation({ data });

      if (result.success) {
        toast.success("Operation completed successfully!");
        return result.data;
      }
    } catch (error) {
      toast.error(error.message || "Something went wrong");
      console.error("Action failed:", error);
    }
  };

  return (
    <Button onClick={() => handleAction(formData)}>
      Execute Action
    </Button>
  );
};

Real-time Subscription Patterns

Reactive Data Pattern

// Component automatically updates when data changes
const TeamDashboard = ({ teamSlug }: { teamSlug: string }) => {
  // Real-time team data
  const team = useQuery(api.teams.queries.getBySlug, { slug: teamSlug });

  // Real-time messages
  const messages = useQuery(
    api.messages.queries.getTeamMessages,
    team?._id ? { teamId: team._id } : "skip"
  );

  // Real-time member list
  const members = useQuery(
    api.teams.queries.getTeamMembers,
    team?._id ? { teamId: team._id } : "skip"
  );

  if (!team) return <LoadingSkeleton />;

  return (
    <div>
      <TeamHeader team={team} />
      <MemberList members={members} />
      <MessageList messages={messages} />
    </div>
  );
};

Optimistic Updates

const useSendMessage = (teamId: Id<"teams">) => {
  const sendMessage = useMutation(api.messages.mutations.send);
  const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
    null,
  );

  const handleSend = async (content: string) => {
    // Show optimistic update immediately
    setOptimisticMessage(content);

    try {
      await sendMessage({ teamId, content });
      // Success - optimistic update is replaced by real data
      setOptimisticMessage(null);
    } catch (error) {
      // Error - rollback optimistic update
      setOptimisticMessage(null);
      throw error;
    }
  };

  return { handleSend, optimisticMessage };
};

External API Integration Patterns

Stripe Integration (Actions)

// Stripe operations use actions for external API calls
export const createCheckoutSession = action({
  args: {
    teamId: v.id("teams"),
    priceId: v.string(),
    successUrl: v.string(),
    cancelUrl: v.string(),
  },
  handler: async (ctx, args) => {
    // Get team and validate permissions
    const team = await ctx.runQuery(api.teams.queries.getById, {
      id: args.teamId,
    });

    if (!team) {
      throw new ConvexError("Team not found");
    }

    // Check user permissions using policy-based access control
    const { userId } = await requireAccess(ctx, {
      teamId: args.teamId,
      teamRole: "owner",
    });

    // Create Stripe checkout session
    const session = await stripe.checkout.sessions.create({
      customer: team.stripeCustomerId,
      payment_method_types: ["card"],
      line_items: [
        {
          price: args.priceId,
          quantity: 1,
        },
      ],
      mode: "subscription",
      success_url: args.successUrl,
      cancel_url: args.cancelUrl,
    });

    return { sessionId: session.id, url: session.url };
  },
});

Email Integration (Actions)

// Email sending uses actions for external API calls
export const sendNotificationEmail = action({
  args: {
    to: v.string(),
    template: v.string(),
    data: v.object({}),
  },
  handler: async (ctx, args) => {
    // Render email template
    const emailHtml = await renderEmailTemplate(args.template, args.data);

    // Send via Resend
    const result = await resend.sendEmail(ctx, {
      from: "TinyKit Pro <noreply@example.com>",
      to: args.to,
      subject: args.data.subject,
      html: emailHtml,
    });

    // Update database with delivery status
    await ctx.runMutation(api.notifications.mutations.updateEmailStatus, {
      notificationId: args.data.notificationId,
      emailSent: true,
      emailSentAt: Date.now(),
    });

    return result;
  },
});

Testing Patterns

Function Testing

// Test Convex functions with proper setup
describe("Team Management", () => {
  let convex: ConvexTestingHelper;

  beforeEach(async () => {
    convex = new ConvexTestingHelper();
    await convex.mutation(api.teams.mutations.create, {
      name: "Test Team",
      description: "A test team",
    });
  });

  it("should allow team owner to update settings", async () => {
    const teamId = await convex.mutation(api.teams.mutations.create, {
      name: "Test Team",
    });

    const result = await convex.mutation(api.teams.mutations.update, {
      teamId,
      updates: { name: "Updated Team" },
    });

    expect(result.success).toBe(true);
  });

  it("should deny non-owners from updating settings", async () => {
    // Test permission denial
    await expect(
      convex.mutation(api.teams.mutations.update, {
        teamId: "unauthorized-team",
        updates: { name: "Hacked Team" },
      }),
    ).rejects.toThrow("Insufficient permissions");
  });
});

Performance Optimization Patterns

Query Optimization

// Batch related queries efficiently
export const getTeamDashboardData = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    // Check permissions once using policy-based access control
    const canAccess = await hasAccess(ctx, {
      teamId: args.teamId,
      teamRole: "member",
    });
    if (!canAccess) {
      return null;
    }

    // Batch all required data
    const [team, members, recentMessages, stats] = await Promise.all([
      ctx.db.get(args.teamId),
      ctx.db
        .query("teamMembers")
        .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
        .collect(),
      ctx.db
        .query("messages")
        .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
        .order("desc")
        .take(10),
      getTeamStats(ctx, args.teamId),
    ]);

    return { team, members, recentMessages, stats };
  },
});

Frontend Performance

// Memoized components for expensive renders
const TeamMemberList = React.memo(({ members }: { members: TeamMember[] }) => {
  const sortedMembers = useMemo(() => {
    // Sort by role: owners first, then admins, then members
    const roleOrder = { owner: 0, admin: 1, member: 2 };
    return members.sort((a, b) =>
      roleOrder[a.teamRole as keyof typeof roleOrder] -
      roleOrder[b.teamRole as keyof typeof roleOrder]
    );
  }, [members]);

  return (
    <div>
      {sortedMembers.map(member => (
        <MemberCard key={member._id} member={member} />
      ))}
    </div>
  );
});

Security Patterns

Input Validation

// Server-side validation with Convex validators
export const createUser = mutation({
  args: {
    email: v.string(),
    firstName: v.string(),
    lastName: v.string(),
    userRole: v.union(v.literal("user"), v.literal("admin")),
  },
  handler: async (ctx, args) => {
    // Additional business logic validation
    if (!args.email.includes("@")) {
      throw new ConvexError("Invalid email address");
    }

    if (args.firstName.length < 1 || args.lastName.length < 1) {
      throw new ConvexError("Name fields are required");
    }

    // Permission check for admin role assignment
    if (args.userRole === "admin") {
      await requireAccess(ctx, {
        userRole: "admin",
      });
    }

    // Execute with validated data
    return await ctx.db.insert("users", args);
  },
});

Data Sanitization

// Sanitize user input for security
export const createAnnouncement = mutation({
  args: {
    title: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    // Sanitize inputs
    const sanitizedTitle = args.title.trim().slice(0, 100);
    const sanitizedContent = args.content.trim().slice(0, 1000);

    // Validate sanitized inputs
    if (!sanitizedTitle || !sanitizedContent) {
      throw new ConvexError("Title and content are required");
    }

    return await ctx.db.insert("announcements", {
      title: sanitizedTitle,
      content: sanitizedContent,
      createdAt: Date.now(),
    });
  },
});

User Profile Helper Functions

Picture URL Resolution

Location: convex/users/helpers.ts

The getUserPictureUrl helper provides consistent picture URL resolution with OAuth provider image support:

// Get user picture URL with proper fallback handling
export async function getUserPictureUrl(
  user: { pictureStorageId?: string; image?: string },
  ctx: { storage: { getUrl: (id: string) => Promise<string | null> } },
): Promise<string | null>;

Usage in Queries

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

export const getUser = query({
  args: { userId: v.string() },
  handler: async (ctx, { userId }) => {
    const user = await ctx.db.get(userId);
    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) - Takes priority when users upload custom avatars
  2. OAuth provider images (image field) - GitHub, Google, Apple profile pictures
  3. Default state (null) - No image available

Implementation Benefits

  • OAuth Integration: Automatically displays GitHub, Google, and Apple profile pictures
  • Seamless Upgrades: User uploads override OAuth images without breaking functionality
  • Consistent API: All components use the same pictureUrl field regardless of image source
  • Performance Optimized: Single helper function used across all user queries
  • Type Safe: Proper TypeScript types for user objects and context

Next.js Configuration

OAuth profile images require Next.js image domain configuration:

// next.config.ts
images: {
  remotePatterns: [
    {
      protocol: "https",
      hostname: "avatars.githubusercontent.com", // GitHub
      pathname: "/**",
    },
    {
      protocol: "https",
      hostname: "lh3.googleusercontent.com", // Google
      pathname: "/**",
    },
    {
      protocol: "https",
      hostname: "appleid.cdn-apple.com", // Apple
      pathname: "/**",
    },
  ],
},

← Previous: Deployment | ← Back to Technical

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro