diff --git a/apps/app/app/(dashboard)/[orgSlug]/components/repo-sidebar.tsx b/apps/app/app/(dashboard)/[orgSlug]/components/repo-sidebar.tsx index c9d1563e..086bd448 100644 --- a/apps/app/app/(dashboard)/[orgSlug]/components/repo-sidebar.tsx +++ b/apps/app/app/(dashboard)/[orgSlug]/components/repo-sidebar.tsx @@ -8,12 +8,16 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@repo/design-system/components/ui/sidebar"; -import { LifeBuoyIcon } from "lucide-react"; +import { GiftIcon, LifeBuoyIcon } from "lucide-react"; import Link from "next/link"; import { getUserOrganizations } from "@/lib/auth"; import { OrganizationSidebarGroup } from "./organization-sidebar-group"; -export const RepoSidebar = async () => { +interface RepoSidebarProps { + orgSlug: string; +} + +export const RepoSidebar = async ({ orgSlug }: RepoSidebarProps) => { const userOrgs = await getUserOrganizations(); const organizations = await database.organization.findMany({ where: { @@ -43,6 +47,12 @@ export const RepoSidebar = async () => { + + + + Referrals + + diff --git a/apps/app/app/(dashboard)/[orgSlug]/layout.tsx b/apps/app/app/(dashboard)/[orgSlug]/layout.tsx index 7370684f..5af4ac30 100644 --- a/apps/app/app/(dashboard)/[orgSlug]/layout.tsx +++ b/apps/app/app/(dashboard)/[orgSlug]/layout.tsx @@ -65,7 +65,7 @@ const OrgLayout = async ({ children, params }: LayoutProps<"/[orgSlug]">) => { return ( - + {!isSubscribed && ( diff --git a/apps/app/app/(dashboard)/[orgSlug]/settings/referrals/components/referral-dashboard.tsx b/apps/app/app/(dashboard)/[orgSlug]/settings/referrals/components/referral-dashboard.tsx new file mode 100644 index 00000000..40e43f7b --- /dev/null +++ b/apps/app/app/(dashboard)/[orgSlug]/settings/referrals/components/referral-dashboard.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { Badge } from "@repo/design-system/components/ui/badge"; +import { Button } from "@repo/design-system/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@repo/design-system/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@repo/design-system/components/ui/table"; +import { + CheckCircleIcon, + ClockIcon, + CopyIcon, + DollarSignIcon, + GiftIcon, + UsersIcon, +} from "lucide-react"; +import { useState } from "react"; +import type { ReferralCode } from "@/lib/referral/get-referral-code"; +import type { ReferralStats } from "@/lib/referral/get-referral-stats"; + +interface ReferralDashboardProps { + referralCode: ReferralCode; + stats: ReferralStats; +} + +const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`; + +const formatDate = (date: Date) => new Date(date).toLocaleDateString(); + +export const ReferralDashboard = ({ + referralCode, + stats, +}: ReferralDashboardProps) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(referralCode.url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const getStatusBadge = (status: "PENDING" | "COMPLETED" | "INVALID") => { + switch (status) { + case "COMPLETED": + return ( + + + Completed + + ); + case "PENDING": + return ( + + + Pending + + ); + default: + return Invalid; + } + }; + + return ( +
+ + +
+ + + Your Referral Link + + + Share this link to earn $5 credit for both you and your referral + when they subscribe. + +
+ +
+ +
+ + {referralCode.url} + +
+
+
+ +
+ + + + Credit Balance + + + + +

+ {formatCurrency(stats.creditBalanceCents)} +

+

+ Applied to future invoices +

+
+
+ + + + + Total Referrals + + + + +

{stats.totalReferrals}

+

+ {stats.completedReferrals} completed, {stats.pendingReferrals}{" "} + pending +

+
+
+ + + + Total Earned + + + +

+ {formatCurrency(stats.totalEarnedCents)} +

+

+ From referral bonuses +

+
+
+
+ + {stats.referralReceived && ( + + + You Were Referred + + You were referred by {stats.referralReceived.referrerName} + + + +
+ {getStatusBadge(stats.referralReceived.status)} + + {stats.referralReceived.status === "COMPLETED" + ? "You received $5 credit" + : "Credit applied after first payment"} + +
+
+
+ )} + + {stats.referralsGiven.length > 0 && ( + + + Referral History + + Organizations you have referred to Ultracite + + + + + + + Organization + Status + Referred On + Credited + + + + {stats.referralsGiven.map((referral) => ( + + + {referral.referredName} + + {getStatusBadge(referral.status)} + + {formatDate(referral.createdAt)} + + + {referral.creditedAt + ? formatDate(referral.creditedAt) + : "-"} + + + ))} + +
+
+
+ )} +
+ ); +}; diff --git a/apps/app/app/(dashboard)/[orgSlug]/settings/referrals/page.tsx b/apps/app/app/(dashboard)/[orgSlug]/settings/referrals/page.tsx new file mode 100644 index 00000000..373bb901 --- /dev/null +++ b/apps/app/app/(dashboard)/[orgSlug]/settings/referrals/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { getCurrentUser, getOrganizationBySlug } from "@/lib/auth"; +import { getReferralCode } from "@/lib/referral/get-referral-code"; +import { getReferralStats } from "@/lib/referral/get-referral-stats"; +import { ReferralDashboard } from "./components/referral-dashboard"; + +export const metadata: Metadata = { + title: "Referrals", + description: "Manage your referral program and earn credits.", +}; + +const ReferralsPage = async ({ + params, +}: PageProps<"/[orgSlug]/settings/referrals">) => { + const user = await getCurrentUser(); + + if (!user) { + redirect("/auth/login"); + } + + const { orgSlug } = await params; + const organization = await getOrganizationBySlug(orgSlug); + + if (!organization) { + notFound(); + } + + const [referralCode, stats] = await Promise.all([ + getReferralCode(organization.id), + getReferralStats(organization.id, organization.stripeCustomerId), + ]); + + return ( +
+
+

Referrals

+

+ Earn $5 credit for each organization you refer. Your referral gets $5 + too. +

+
+ +
+ ); +}; + +export default ReferralsPage; diff --git a/apps/app/app/api/stripe/webhooks/route.ts b/apps/app/app/api/stripe/webhooks/route.ts index a5edb36c..89a920b6 100644 --- a/apps/app/app/api/stripe/webhooks/route.ts +++ b/apps/app/app/api/stripe/webhooks/route.ts @@ -2,6 +2,10 @@ import { database } from "@repo/backend/database"; import { type NextRequest, NextResponse } from "next/server"; import type Stripe from "stripe"; import { env } from "@/lib/env"; +import { + applyPendingReferrerCredits, + applyReferralCredits, +} from "@/lib/referral/apply-credits"; import { stripe } from "@/lib/stripe"; export const POST = async (request: NextRequest) => { @@ -58,6 +62,27 @@ export const POST = async (request: NextRequest) => { return new Response("OK", { status: 200 }); } + case "invoice.paid": { + const invoice = event.data.object as Stripe.Invoice; + + // Only apply referral credits on first invoice (subscription creation) + if (invoice.billing_reason === "subscription_create") { + // Run independently so one failure doesn't block the other + const results = await Promise.allSettled([ + applyReferralCredits(invoice), + applyPendingReferrerCredits(invoice), + ]); + + for (const result of results) { + if (result.status === "rejected") { + console.error("Referral credit error:", result.reason); + } + } + } + + return new Response("OK", { status: 200 }); + } + default: { return new Response("OK", { status: 200 }); } diff --git a/apps/app/app/auth/oauth/route.ts b/apps/app/app/auth/oauth/route.ts index 5b14824c..29883ac1 100644 --- a/apps/app/app/auth/oauth/route.ts +++ b/apps/app/app/auth/oauth/route.ts @@ -1,5 +1,7 @@ +import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import { syncGitHubOrganizations } from "@/lib/github/sync-orgs"; +import { REFERRAL_COOKIE } from "@/lib/referral/constants"; import { createClient } from "@/lib/supabase/server"; export async function GET(request: Request) { @@ -34,12 +36,22 @@ export async function GET(request: Request) { if (providerToken && userId) { try { + // Get referral code from cookie + const cookieStore = await cookies(); + const referralCode = cookieStore.get(REFERRAL_COOKIE)?.value; + const { organizations } = await syncGitHubOrganizations( providerToken, userId, - userEmail + userEmail, + referralCode ); + // Clear referral cookie after use + if (referralCode) { + cookieStore.delete(REFERRAL_COOKIE); + } + // Redirect to first organization if available if (organizations.length > 0 && next === "/") { next = `/${organizations[0].slug}`; diff --git a/apps/app/app/r/[code]/route.ts b/apps/app/app/r/[code]/route.ts new file mode 100644 index 00000000..964d0220 --- /dev/null +++ b/apps/app/app/r/[code]/route.ts @@ -0,0 +1,34 @@ +import { database } from "@repo/backend/database"; +import { cookies } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; +import { REFERRAL_COOKIE } from "@/lib/referral/constants"; + +const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days + +export const GET = async ( + _request: NextRequest, + { params }: { params: Promise<{ code: string }> } +) => { + const { code } = await params; + + // Validate code exists + const referralCode = await database.referralCode.findUnique({ + where: { code }, + }); + + if (!referralCode) { + return NextResponse.redirect(new URL("/", _request.url)); + } + + // Set referral cookie + const cookieStore = await cookies(); + cookieStore.set(REFERRAL_COOKIE, code, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: COOKIE_MAX_AGE, + path: "/", + }); + + return NextResponse.redirect(new URL("/auth/login", _request.url)); +}; diff --git a/apps/app/lib/error.ts b/apps/app/lib/error.ts index 159224f0..f3f0c93c 100644 --- a/apps/app/lib/error.ts +++ b/apps/app/lib/error.ts @@ -8,42 +8,54 @@ export const parseError = (error: unknown): string => { return String(error); }; +const getErrorStatus = (error: unknown): number => { + if (error && typeof error === "object" && "status" in error) { + return (error as { status: number }).status; + } + return 0; +}; + +const parseRetryAfter = (error: unknown): Date | undefined => { + if ( + !error || + typeof error !== "object" || + !("response" in error) || + !error.response + ) { + return undefined; + } + + const response = error.response as { headers?: Record }; + const resetTimestamp = response.headers?.["x-ratelimit-reset"]; + const retryAfterSecs = response.headers?.["retry-after"]; + + if (resetTimestamp) { + return new Date(Number.parseInt(resetTimestamp, 10) * 1000); + } + + if (retryAfterSecs) { + return new Date(Date.now() + Number.parseInt(retryAfterSecs, 10) * 1000); + } + + return undefined; +}; + /** * Check if an error is a GitHub rate limit error and throw RetryableError if so. * Otherwise, re-throw the original error. */ export const handleGitHubError = (error: unknown, context: string): never => { - // Check for rate limit errors (403 with rate limit message, or 429) if (error && typeof error === "object") { - const status = "status" in error ? (error as { status: number }).status : 0; + const status = getErrorStatus(error); const message = parseError(error); - // GitHub returns 403 for rate limits with specific messages, or 429 if (status === 429 || (status === 403 && message.includes("rate limit"))) { - // Check for retry-after header or x-ratelimit-reset - let retryAfter: Date | undefined; - - if ("response" in error && error.response) { - const response = error.response as { headers?: Record }; - const resetTimestamp = response.headers?.["x-ratelimit-reset"]; - const retryAfterSecs = response.headers?.["retry-after"]; - - if (resetTimestamp) { - retryAfter = new Date(Number.parseInt(resetTimestamp, 10) * 1000); - } else if (retryAfterSecs) { - retryAfter = new Date( - Date.now() + Number.parseInt(retryAfterSecs, 10) * 1000 - ); - } - } - - // Default to 60 seconds if no retry info available + const retryAfter = parseRetryAfter(error); throw new RetryableError(`${context}: GitHub rate limit exceeded`, { retryAfter: retryAfter ?? "60s", }); } - // Check for secondary rate limits (abuse detection) if (status === 403 && message.includes("abuse")) { throw new RetryableError(`${context}: GitHub abuse detection triggered`, { retryAfter: "60s", @@ -51,6 +63,5 @@ export const handleGitHubError = (error: unknown, context: string): never => { } } - // Not a rate limit error, re-throw throw new Error(`${context}: ${parseError(error)}`); }; diff --git a/apps/app/lib/github/sync-orgs.ts b/apps/app/lib/github/sync-orgs.ts index abfd6a2c..3a07d239 100644 --- a/apps/app/lib/github/sync-orgs.ts +++ b/apps/app/lib/github/sync-orgs.ts @@ -2,6 +2,7 @@ import "server-only"; import { database } from "@repo/backend/database"; import { Octokit } from "octokit"; +import { processReferral } from "@/lib/referral/process-referral"; import { getGitHubApp, getInstallationOctokit } from "./app"; interface GitHubOrg { @@ -80,25 +81,83 @@ async function checkGitHubAppInstallation( } } +async function upsertOrganization(org: GitHubOrg) { + const slug = org.login.toLowerCase(); + + try { + return await database.organization.upsert({ + where: { githubOrgId: org.id }, + create: { + name: org.name ?? org.login, + slug, + githubOrgId: org.id, + githubOrgLogin: org.login, + githubOrgType: org.type, + }, + update: { + name: org.name ?? org.login, + githubOrgLogin: org.login, + githubOrgType: org.type, + }, + }); + } catch (error) { + if (error instanceof Error && error.message.includes("Unique constraint")) { + const existing = await database.organization.findUnique({ + where: { githubOrgId: org.id }, + }); + + if (!existing) { + console.warn( + `Skipping org ${org.login}: slug "${slug}" is taken by another organization` + ); + } + + return existing; + } + throw error; + } +} + +async function syncInstallation( + organization: { id: string; githubInstallationId: number | null }, + org: GitHubOrg +) { + if (organization.githubInstallationId) { + await syncRepositories(organization.id, organization.githubInstallationId); + return; + } + + const installation = await checkGitHubAppInstallation(org.login, org.type); + + if (installation) { + await database.organization.update({ + where: { id: organization.id }, + data: { + githubInstallationId: installation.id, + githubAccountLogin: org.login, + installedAt: new Date(installation.createdAt), + }, + }); + + await syncRepositories(organization.id, installation.id); + } +} + export async function syncGitHubOrganizations( providerToken: string, userId: string, - userEmail: string + userEmail: string, + referralCode?: string ): Promise<{ synced: number; organizations: { id: string; slug: string }[] }> { const octokit = new Octokit({ auth: providerToken }); - // Fetch the authenticated user's info (for personal account) const { data: user } = await octokit.rest.users.getAuthenticated(); - // Fetch all organizations the user belongs to (with pagination) const orgs = await octokit.paginate( octokit.rest.orgs.listForAuthenticatedUser, - { - per_page: 100, - } + { per_page: 100 } ); - // Combine personal account and organizations const allOrgs: GitHubOrg[] = [ { id: user.id, @@ -110,13 +169,13 @@ export async function syncGitHubOrganizations( id: org.id, login: org.login, type: "Organization" as const, - name: org.login, // GitHub orgs don't always have a display name in this endpoint + name: org.login, })), ]; const syncedOrganizations: { id: string; slug: string }[] = []; + let referralApplied = false; - // Ensure user exists in database await database.user.upsert({ where: { id: userId }, create: { id: userId, email: userEmail }, @@ -124,59 +183,12 @@ export async function syncGitHubOrganizations( }); for (const org of allOrgs) { - // Generate a slug from the login (already lowercase with valid chars) - const slug = org.login.toLowerCase(); - - // Use upsert to atomically find-or-create the organization by GitHub ID - // This prevents race conditions when multiple users from the same org log in simultaneously - let organization: { - id: string; - slug: string; - githubInstallationId: number | null; - } | null = null; - - try { - organization = await database.organization.upsert({ - where: { githubOrgId: org.id }, - create: { - name: org.name ?? org.login, - slug, - githubOrgId: org.id, - githubOrgLogin: org.login, - githubOrgType: org.type, - }, - update: { - name: org.name ?? org.login, - githubOrgLogin: org.login, - githubOrgType: org.type, - }, - }); - } catch (error) { - // Handle slug unique constraint violation - another org already has this slug - // This can happen if a different GitHub org claimed the slug first - if ( - error instanceof Error && - error.message.includes("Unique constraint") - ) { - // Try to find the org by githubOrgId in case it was created by a concurrent request - organization = await database.organization.findUnique({ - where: { githubOrgId: org.id }, - }); - - if (!organization) { - // Slug is taken by a different org - skip this one - console.warn( - `Skipping org ${org.login}: slug "${slug}" is taken by another organization` - ); - continue; - } - } else { - throw error; - } + const organization = await upsertOrganization(org); + + if (!organization) { + continue; } - // Add user as member if not already (using upsert to prevent race conditions) - // For personal account, user is owner. For orgs, start as member const role = org.type === "User" ? "OWNER" : "MEMBER"; await database.organizationMember.upsert({ @@ -191,39 +203,18 @@ export async function syncGitHubOrganizations( organizationId: organization.id, role, }, - update: {}, // Don't update role if membership already exists + update: {}, }); - // Check if GitHub App is already installed on this account - // and auto-link if not already linked - if (organization.githubInstallationId) { - // Already has installation, just sync repos to ensure they're up to date - await syncRepositories( - organization.id, - organization.githubInstallationId - ); - } else { - const installation = await checkGitHubAppInstallation( - org.login, - org.type - ); - - if (installation) { - // Link the installation to this organization - await database.organization.update({ - where: { id: organization.id }, - data: { - githubInstallationId: installation.id, - githubAccountLogin: org.login, - installedAt: new Date(installation.createdAt), - }, - }); - - // Sync repositories from the installation - await syncRepositories(organization.id, installation.id); + if (referralCode && !referralApplied) { + const result = await processReferral(referralCode, organization.id); + if (result.success) { + referralApplied = true; } } + await syncInstallation(organization, org); + syncedOrganizations.push({ id: organization.id, slug: organization.slug, diff --git a/apps/app/lib/referral/apply-credits.ts b/apps/app/lib/referral/apply-credits.ts new file mode 100644 index 00000000..70d8d308 --- /dev/null +++ b/apps/app/lib/referral/apply-credits.ts @@ -0,0 +1,190 @@ +import "server-only"; + +import { database } from "@repo/backend/database"; +import type Stripe from "stripe"; +import { stripe } from "@/lib/stripe"; +import { REFERRAL_CREDIT_CENTS } from "./constants"; + +async function applyCredit( + customerId: string, + description: string +): Promise { + try { + await stripe.customers.createBalanceTransaction(customerId, { + amount: -REFERRAL_CREDIT_CENTS, // Negative = credit + currency: "usd", + description, + }); + return true; + } catch (error) { + console.error("Failed to apply referral credit:", error); + return false; + } +} + +export async function applyReferralCredits(invoice: Stripe.Invoice) { + const customerId = invoice.customer as string; + + // Find org by Stripe customer ID + const org = await database.organization.findFirst({ + where: { stripeCustomerId: customerId }, + include: { + referralReceived: { + include: { + referrerOrganization: true, + }, + }, + }, + }); + + // Skip if no referral or already fully completed + if (!org?.referralReceived || org.referralReceived.status !== "PENDING") { + return; + } + + const referral = org.referralReceived; + const referrerOrg = referral.referrerOrganization; + const now = new Date(); + + // Apply referred org credit if not already done + let referredCredited = referral.referredCreditedAt !== null; + + if (!referredCredited) { + // Atomic claim: only one concurrent request can set referredCreditedAt + // from null, preventing duplicate Stripe credits on webhook retry + const claimed = await database.referral.updateMany({ + where: { + id: referral.id, + referredCreditedAt: null, + status: "PENDING", + }, + data: { + paidInvoiceId: invoice.id, + referredCreditedAt: now, + }, + }); + + if (claimed.count === 0) { + return; + } + + referredCredited = await applyCredit(customerId, "Referral signup bonus"); + + if (!referredCredited) { + // Roll back claim if credit failed so it can be retried + await database.referral.update({ + where: { id: referral.id }, + data: { referredCreditedAt: null, paidInvoiceId: null }, + }); + return; + } + } + + // Apply credit to referrer org only if they have a Stripe customer + // If not, we'll apply it when they subscribe (via applyPendingReferrerCredits) + let referrerCredited = referral.referrerCreditedAt !== null; + + if (!referrerCredited && referrerOrg.stripeCustomerId) { + // Atomic claim for referrer credit + const referrerClaimed = await database.referral.updateMany({ + where: { + id: referral.id, + referrerCreditedAt: null, + }, + data: { referrerCreditedAt: now }, + }); + + if (referrerClaimed.count > 0) { + referrerCredited = await applyCredit( + referrerOrg.stripeCustomerId, + `Referral bonus - ${org.name}` + ); + + if (!referrerCredited) { + // Roll back timestamp if credit failed so it can be retried + await database.referral.update({ + where: { id: referral.id }, + data: { referrerCreditedAt: null }, + }); + } + } + } + + // Only mark completed if BOTH credits were actually applied + // Keep as PENDING if referrer hasn't subscribed yet or credit failed + if (referredCredited && referrerCredited) { + await database.referral.update({ + where: { id: referral.id }, + data: { status: "COMPLETED" }, + }); + } +} + +/** + * Apply pending referrer credits when an organization subscribes. + * This handles the case where org A referred org B, B paid first, + * and now A is subscribing and should receive their referrer credit. + */ +export async function applyPendingReferrerCredits(invoice: Stripe.Invoice) { + const customerId = invoice.customer as string; + + // Find org by Stripe customer ID + const org = await database.organization.findFirst({ + where: { stripeCustomerId: customerId }, + }); + + if (!org) { + return; + } + + // Find any referrals where this org is the referrer and hasn't been credited yet + const pendingReferrals = await database.referral.findMany({ + where: { + referrerOrganizationId: org.id, + referrerCreditedAt: null, + paidInvoiceId: { not: null }, // Referred org has already paid + referredCreditedAt: { not: null }, // Referred org credit was applied + }, + include: { + referredOrganization: true, + }, + }); + + const now = new Date(); + + for (const referral of pendingReferrals) { + // Atomic claim: only one concurrent request can set referrerCreditedAt + // from null, preventing duplicate Stripe credits on webhook retry + const claimed = await database.referral.updateMany({ + where: { + id: referral.id, + referrerCreditedAt: null, + }, + data: { referrerCreditedAt: now }, + }); + + if (claimed.count === 0) { + continue; + } + + const credited = await applyCredit( + customerId, + `Referral bonus - ${referral.referredOrganization.name}` + ); + + if (!credited) { + // Roll back timestamp if credit failed so it can be retried + await database.referral.update({ + where: { id: referral.id }, + data: { referrerCreditedAt: null }, + }); + continue; + } + + // Referred credit already confirmed by query filter, mark completed + await database.referral.update({ + where: { id: referral.id }, + data: { status: "COMPLETED" }, + }); + } +} diff --git a/apps/app/lib/referral/constants.ts b/apps/app/lib/referral/constants.ts new file mode 100644 index 00000000..680f664d --- /dev/null +++ b/apps/app/lib/referral/constants.ts @@ -0,0 +1,2 @@ +export const REFERRAL_CREDIT_CENTS = 500; // $5.00 +export const REFERRAL_COOKIE = "ultracite_referral"; diff --git a/apps/app/lib/referral/generate-code.ts b/apps/app/lib/referral/generate-code.ts new file mode 100644 index 00000000..d9d386d8 --- /dev/null +++ b/apps/app/lib/referral/generate-code.ts @@ -0,0 +1,66 @@ +import "server-only"; + +import { database } from "@repo/backend/database"; +import { customAlphabet } from "nanoid"; +import { env } from "@/lib/env"; + +// 8-char lowercase alphanumeric for clean URLs +const generateCode = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 8); + +export async function getOrCreateReferralCode(organizationId: string) { + const existing = await database.referralCode.findUnique({ + where: { organizationId }, + }); + + if (existing) { + return existing; + } + + // Generate unique code with collision check + const maxAttempts = 5; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const code = generateCode(); + const collision = await database.referralCode.findUnique({ + where: { code }, + }); + + if (!collision) { + try { + return await database.referralCode.create({ + data: { + code, + organizationId, + }, + }); + } catch (error) { + // Handle race condition: another request created a code for this org + if ( + error instanceof Error && + error.message.includes("Unique constraint") + ) { + const created = await database.referralCode.findUnique({ + where: { organizationId }, + }); + if (created) { + return created; + } + // If not found by organizationId, it was a code collision - retry + continue; + } + throw error; + } + } + } + + throw new Error("Failed to generate unique referral code after max attempts"); +} + +export function getReferralUrl(code: string) { + const baseUrl = + process.env.NODE_ENV === "production" + ? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}` + : "http://localhost:3002"; + + return `${baseUrl}/r/${code}`; +} diff --git a/apps/app/lib/referral/get-referral-code.ts b/apps/app/lib/referral/get-referral-code.ts new file mode 100644 index 00000000..9bd2a7c4 --- /dev/null +++ b/apps/app/lib/referral/get-referral-code.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import { + getOrCreateReferralCode, + getReferralUrl, +} from "@/lib/referral/generate-code"; + +export interface ReferralCode { + code: string; + url: string; + timesUsed: number; +} + +export async function getReferralCode( + organizationId: string +): Promise { + const referralCode = await getOrCreateReferralCode(organizationId); + const referralUrl = getReferralUrl(referralCode.code); + + return { + code: referralCode.code, + url: referralUrl, + timesUsed: referralCode.timesUsed, + }; +} diff --git a/apps/app/lib/referral/get-referral-stats.ts b/apps/app/lib/referral/get-referral-stats.ts new file mode 100644 index 00000000..497ddc77 --- /dev/null +++ b/apps/app/lib/referral/get-referral-stats.ts @@ -0,0 +1,97 @@ +import "server-only"; + +import { database } from "@repo/backend/database"; +import { stripe } from "@/lib/stripe"; +import { REFERRAL_CREDIT_CENTS } from "./constants"; + +export interface ReferralStats { + creditBalanceCents: number; + totalReferrals: number; + completedReferrals: number; + pendingReferrals: number; + totalEarnedCents: number; + referralsGiven: { + id: string; + referredName: string; + status: "PENDING" | "COMPLETED" | "INVALID"; + creditedAt: Date | null; + createdAt: Date; + }[]; + referralReceived: { + referrerName: string; + status: "PENDING" | "COMPLETED" | "INVALID"; + creditedAt: Date | null; + createdAt: Date; + } | null; +} + +export async function getReferralStats( + organizationId: string, + stripeCustomerId: string | null +): Promise { + // Get referrals given by this org + const referralsGiven = await database.referral.findMany({ + where: { referrerOrganizationId: organizationId }, + include: { + referredOrganization: { + select: { name: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + // Get referral received by this org (if any) + const referralReceived = await database.referral.findUnique({ + where: { referredOrganizationId: organizationId }, + include: { + referrerOrganization: { + select: { name: true }, + }, + }, + }); + + // Get credit balance from Stripe if customer exists + let creditBalanceCents = 0; + if (stripeCustomerId) { + try { + const customer = await stripe.customers.retrieve(stripeCustomerId); + if ("balance" in customer) { + // Stripe balance is positive for amount owed, negative for credit + creditBalanceCents = customer.balance < 0 ? -customer.balance : 0; + } + } catch { + // Customer not found or error, keep balance at 0 + } + } + + // Calculate stats + const completedReferralsList = referralsGiven.filter( + (r) => r.status === "COMPLETED" + ); + const totalEarnedCents = + completedReferralsList.length * REFERRAL_CREDIT_CENTS; + + return { + creditBalanceCents, + totalReferrals: referralsGiven.length, + completedReferrals: completedReferralsList.length, + pendingReferrals: referralsGiven.filter((r) => r.status === "PENDING") + .length, + totalEarnedCents, + referralsGiven: referralsGiven.map((r) => ({ + id: r.id, + referredName: r.referredOrganization.name, + status: r.status, + creditedAt: r.referrerCreditedAt, + createdAt: r.createdAt, + })), + referralReceived: referralReceived + ? { + referrerName: referralReceived.referrerOrganization.name, + status: referralReceived.status, + creditedAt: referralReceived.referredCreditedAt, + createdAt: referralReceived.createdAt, + } + : null, + }; +} diff --git a/apps/app/lib/referral/process-referral.ts b/apps/app/lib/referral/process-referral.ts new file mode 100644 index 00000000..5955854c --- /dev/null +++ b/apps/app/lib/referral/process-referral.ts @@ -0,0 +1,84 @@ +import "server-only"; + +import { database } from "@repo/backend/database"; + +interface ProcessReferralResult { + success: boolean; + error?: string; +} + +export async function processReferral( + referralCode: string, + referredOrganizationId: string +): Promise { + // Find the referral code and its org + const code = await database.referralCode.findUnique({ + where: { code: referralCode }, + include: { organization: true }, + }); + + if (!code) { + return { success: false, error: "Invalid referral code" }; + } + + // Prevent self-referral + if (code.organizationId === referredOrganizationId) { + return { success: false, error: "Cannot refer yourself" }; + } + + // Create the referral record atomically, handling race conditions + try { + await database.$transaction(async (tx) => { + // Check and create in same transaction to prevent TOCTOU + const existingReferral = await tx.referral.findUnique({ + where: { referredOrganizationId }, + }); + + if (existingReferral) { + throw new Error("Organization already referred"); + } + + // Check if org already has an active subscription (stripeCustomerId indicates they've subscribed) + const referredOrg = await tx.organization.findUnique({ + where: { id: referredOrganizationId }, + select: { stripeCustomerId: true }, + }); + + if (referredOrg?.stripeCustomerId) { + throw new Error("Organization already subscribed"); + } + + await tx.referral.create({ + data: { + referrerOrganizationId: code.organizationId, + referredOrganizationId, + status: "PENDING", + }, + }); + + await tx.referralCode.update({ + where: { id: code.id }, + data: { timesUsed: { increment: 1 } }, + }); + }); + + return { success: true }; + } catch (error) { + if (error instanceof Error) { + // Handle explicit checks and unique constraint race condition + if (error.message === "Organization already referred") { + return { success: false, error: "Organization already referred" }; + } + if (error.message === "Organization already subscribed") { + return { + success: false, + error: "Organization already has a subscription", + }; + } + if (error.message.includes("Unique constraint")) { + return { success: false, error: "Organization already referred" }; + } + } + throw error; + } +} diff --git a/apps/app/lib/steps/add-pr-comment.ts b/apps/app/lib/steps/add-pr-comment.ts index d223c52c..d866376f 100644 --- a/apps/app/lib/steps/add-pr-comment.ts +++ b/apps/app/lib/steps/add-pr-comment.ts @@ -9,36 +9,29 @@ export async function addPRComment( ): Promise { "use step"; - let octokit; - - try { - octokit = await getInstallationOctokit(installationId); - } catch (error) { - throw new Error( - `[addPRComment] Failed to get GitHub client: ${parseError(error)}` - ); - } + const octokit = await getInstallationOctokit(installationId).catch( + (error: unknown) => { + throw new Error( + `[addPRComment] Failed to get GitHub client: ${parseError(error)}` + ); + } + ); const [owner, repo] = repoFullName.split("/"); - let response; - - try { - response = await octokit.request( - "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", - { - owner, - repo, - issue_number: prNumber, - body, - headers: { - "X-GitHub-Api-Version": "2022-11-28", - }, - } - ); - } catch (error) { - throw new Error(`Failed to add PR comment: ${parseError(error)}`); - } + const response = await octokit + .request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", { + owner, + repo, + issue_number: prNumber, + body, + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + .catch((error: unknown) => { + throw new Error(`Failed to add PR comment: ${parseError(error)}`); + }); if (response.status !== 201) { throw new Error(`Failed to add PR comment with status ${response.status}`); diff --git a/apps/app/lib/steps/check-existing-pr.ts b/apps/app/lib/steps/check-existing-pr.ts index a732a175..36147e89 100644 --- a/apps/app/lib/steps/check-existing-pr.ts +++ b/apps/app/lib/steps/check-existing-pr.ts @@ -15,15 +15,13 @@ export async function checkExistingPR( const [owner, repo] = repoFullName.split("/"); - let octokit; - - try { - octokit = await getInstallationOctokit(installationId); - } catch (error) { - throw new Error( - `[checkExistingPR] Failed to get GitHub client: ${parseError(error)}` - ); - } + const octokit = await getInstallationOctokit(installationId).catch( + (error: unknown) => { + throw new Error( + `[checkExistingPR] Failed to get GitHub client: ${parseError(error)}` + ); + } + ); // List open PRs and check if any have a head branch starting with ultracite/fix- let pulls: Awaited>["data"]; diff --git a/apps/app/lib/steps/check-push-access.ts b/apps/app/lib/steps/check-push-access.ts index 21abcd0a..4cb7bf90 100644 --- a/apps/app/lib/steps/check-push-access.ts +++ b/apps/app/lib/steps/check-push-access.ts @@ -16,15 +16,13 @@ export async function checkPushAccess( const [owner, repo] = repoFullName.split("/"); - let octokit; - - try { - octokit = await getInstallationOctokit(installationId); - } catch (error) { - throw new Error( - `[checkPushAccess] Failed to get GitHub client: ${parseError(error)}` - ); - } + const octokit = await getInstallationOctokit(installationId).catch( + (error: unknown) => { + throw new Error( + `[checkPushAccess] Failed to get GitHub client: ${parseError(error)}` + ); + } + ); // Check if the repository is archived let repoData: Awaited>["data"]; diff --git a/apps/app/lib/steps/create-branch.ts b/apps/app/lib/steps/create-branch.ts index 9d5b1dd2..6cbad7f8 100644 --- a/apps/app/lib/steps/create-branch.ts +++ b/apps/app/lib/steps/create-branch.ts @@ -17,17 +17,11 @@ export async function createBranch(sandboxId: string): Promise { const branchName = `ultracite/fix-${nanoid()}`; - let checkoutResult; - - try { - checkoutResult = await sandbox.runCommand("git", [ - "checkout", - "-b", - branchName, - ]); - } catch (error) { - throw new Error(`Failed to create branch: ${parseError(error)}`); - } + const checkoutResult = await sandbox + .runCommand("git", ["checkout", "-b", branchName]) + .catch((error: unknown) => { + throw new Error(`Failed to create branch: ${parseError(error)}`); + }); if (checkoutResult.exitCode !== 0) { const output = await checkoutResult.output("both"); diff --git a/apps/app/lib/steps/create-lint-run.ts b/apps/app/lib/steps/create-lint-run.ts index 39101ec5..8422f225 100644 --- a/apps/app/lib/steps/create-lint-run.ts +++ b/apps/app/lib/steps/create-lint-run.ts @@ -4,10 +4,8 @@ import { parseError } from "@/lib/error"; export async function createLintRun(organizationId: string, repoId: string) { "use step"; - let lintRun; - - try { - lintRun = await database.lintRun.create({ + const lintRun = await database.lintRun + .create({ data: { organizationId, repoId, @@ -17,10 +15,10 @@ export async function createLintRun(organizationId: string, repoId: string) { select: { id: true, }, + }) + .catch((error: unknown) => { + throw new Error(`Failed to create lint run: ${parseError(error)}`); }); - } catch (error) { - throw new Error(`Failed to create lint run: ${parseError(error)}`); - } return lintRun.id; } diff --git a/apps/app/lib/steps/create-pr.ts b/apps/app/lib/steps/create-pr.ts index 461dac8b..c3ca1707 100644 --- a/apps/app/lib/steps/create-pr.ts +++ b/apps/app/lib/steps/create-pr.ts @@ -26,15 +26,13 @@ export async function createPullRequest( changelog, } = params; - let octokit; - - try { - octokit = await getInstallationOctokit(installationId); - } catch (error) { - throw new Error( - `[createPullRequest] Failed to get GitHub client: ${parseError(error)}` - ); - } + const octokit = await getInstallationOctokit(installationId).catch( + (error: unknown) => { + throw new Error( + `[createPullRequest] Failed to get GitHub client: ${parseError(error)}` + ); + } + ); const [owner, repo] = repoFullName.split("/"); diff --git a/apps/app/lib/steps/fix-lint.ts b/apps/app/lib/steps/fix-lint.ts index 3abc7ce3..2328ea8b 100644 --- a/apps/app/lib/steps/fix-lint.ts +++ b/apps/app/lib/steps/fix-lint.ts @@ -18,36 +18,32 @@ export async function fixLint(sandboxId: string): Promise { throw new Error(`[fixLint] Failed to get sandbox: ${parseError(error)}`); } - let result; - - try { - result = await sandbox.runCommand("nlx", ["ultracite", "fix"]); - } catch (error) { - throw new Error(`Failed to run ultracite fix: ${parseError(error)}`); - } + const result = await sandbox + .runCommand("nlx", ["ultracite", "fix"]) + .catch((error: unknown) => { + throw new Error(`Failed to run ultracite fix: ${parseError(error)}`); + }); const output = await result.output("both"); // Check if there are uncommitted changes - let diffResult; - - try { - diffResult = await sandbox.runCommand("git", ["diff", "--name-only"]); - } catch (error) { - throw new Error(`[fixLint] Failed to check git diff: ${parseError(error)}`); - } + const diffResult = await sandbox + .runCommand("git", ["diff", "--name-only"]) + .catch((error: unknown) => { + throw new Error( + `[fixLint] Failed to check git diff: ${parseError(error)}` + ); + }); const diffOutput = await diffResult.stdout(); const hasChanges = Boolean(diffOutput.trim()); // Run check to see if there are remaining issues (non-zero exit = issues remain) - let checkResult; - - try { - checkResult = await sandbox.runCommand("nlx", ["ultracite", "check"]); - } catch (error) { - throw new Error(`Failed to run ultracite check: ${parseError(error)}`); - } + const checkResult = await sandbox + .runCommand("nlx", ["ultracite", "check"]) + .catch((error: unknown) => { + throw new Error(`Failed to run ultracite check: ${parseError(error)}`); + }); const hasRemainingIssues = checkResult.exitCode !== 0; diff --git a/apps/app/lib/steps/generate-changelog.ts b/apps/app/lib/steps/generate-changelog.ts index 74456f02..5e9402df 100644 --- a/apps/app/lib/steps/generate-changelog.ts +++ b/apps/app/lib/steps/generate-changelog.ts @@ -38,16 +38,16 @@ export async function generateChangelog( const escapedApiKey = env.VERCEL_AI_GATEWAY_API_KEY.replace(/'/g, "'\\''"); // Run claude with Vercel AI Gateway env vars set inline - let result; - - try { - result = await sandbox.runCommand("sh", [ + const result = await sandbox + .runCommand("sh", [ "-c", `ANTHROPIC_BASE_URL='https://ai-gateway.vercel.sh' ANTHROPIC_AUTH_TOKEN='${escapedApiKey}' ANTHROPIC_API_KEY='' claude -p '${escapedPrompt}' --dangerously-skip-permissions --model claude-haiku-4-5 --max-turns 5`, - ]); - } catch (error) { - throw new Error(`Failed to run Claude for changelog: ${parseError(error)}`); - } + ]) + .catch((error: unknown) => { + throw new Error( + `Failed to run Claude for changelog: ${parseError(error)}` + ); + }); const output = await result.output("both"); const success = result.exitCode === 0; diff --git a/apps/app/lib/steps/get-github-token.ts b/apps/app/lib/steps/get-github-token.ts index 33b172fd..cc7ee646 100644 --- a/apps/app/lib/steps/get-github-token.ts +++ b/apps/app/lib/steps/get-github-token.ts @@ -4,26 +4,19 @@ import { getInstallationOctokit } from "@/lib/github/app"; export async function getGitHubToken(installationId: number): Promise { "use step"; - let octokit; + const octokit = await getInstallationOctokit(installationId).catch( + (error: unknown) => { + throw new Error( + `[getGitHubToken] Failed to get GitHub client: ${parseError(error)}` + ); + } + ); - try { - octokit = await getInstallationOctokit(installationId); - } catch (error) { - throw new Error( - `[getGitHubToken] Failed to get GitHub client: ${parseError(error)}` - ); - } - - let token; - - try { - const auth = (await octokit.auth({ - type: "installation", - })) as { token: string }; - token = auth.token; - } catch (error) { + const auth = await ( + octokit.auth({ type: "installation" }) as Promise<{ token: string }> + ).catch((error: unknown) => { throw new Error(`Failed to get GitHub token: ${parseError(error)}`); - } + }); - return token; + return auth.token; } diff --git a/apps/app/lib/steps/has-uncommitted-changes.ts b/apps/app/lib/steps/has-uncommitted-changes.ts index f23ce4ba..2139e90b 100644 --- a/apps/app/lib/steps/has-uncommitted-changes.ts +++ b/apps/app/lib/steps/has-uncommitted-changes.ts @@ -16,15 +16,13 @@ export async function hasUncommittedChanges( ); } - let diffResult; - - try { - diffResult = await sandbox.runCommand("git", ["diff", "--name-only"]); - } catch (error) { - throw new Error( - `[hasUncommittedChanges] Failed to check git diff: ${parseError(error)}` - ); - } + const diffResult = await sandbox + .runCommand("git", ["diff", "--name-only"]) + .catch((error: unknown) => { + throw new Error( + `[hasUncommittedChanges] Failed to check git diff: ${parseError(error)}` + ); + }); const diffOutput = await diffResult.stdout(); diff --git a/apps/app/lib/steps/install-dependencies.ts b/apps/app/lib/steps/install-dependencies.ts index 6eab9140..80ba215e 100644 --- a/apps/app/lib/steps/install-dependencies.ts +++ b/apps/app/lib/steps/install-dependencies.ts @@ -29,13 +29,11 @@ export async function installDependencies(sandboxId: string): Promise { } // Detect the package manager using `ni -v` - let result; - - try { - result = await sandbox.runCommand("ni", ["-v"]); - } catch (error) { - throw new Error(`Failed to detect package manager: ${parseError(error)}`); - } + const result = await sandbox + .runCommand("ni", ["-v"]) + .catch((error: unknown) => { + throw new Error(`Failed to detect package manager: ${parseError(error)}`); + }); const output = await result.stdout(); diff --git a/apps/app/lib/steps/record-billing-usage.ts b/apps/app/lib/steps/record-billing-usage.ts index db3ccd67..725194f8 100644 --- a/apps/app/lib/steps/record-billing-usage.ts +++ b/apps/app/lib/steps/record-billing-usage.ts @@ -13,15 +13,13 @@ export async function recordBillingUsage( // Get the step's unique ID - stable across retries const { stepId } = getStepMetadata(); - let lintRun; - - try { - lintRun = await database.lintRun.findUnique({ + const lintRun = await database.lintRun + .findUnique({ where: { id: lintRunId }, + }) + .catch((error: unknown) => { + throw new Error(`Failed to fetch lint run: ${parseError(error)}`); }); - } catch (error) { - throw new Error(`Failed to fetch lint run: ${parseError(error)}`); - } if (!lintRun) { throw new FatalError(`Lint run not found: ${lintRunId}`); diff --git a/apps/app/lib/steps/run-claude-code.ts b/apps/app/lib/steps/run-claude-code.ts index 1b231380..71f39cfe 100644 --- a/apps/app/lib/steps/run-claude-code.ts +++ b/apps/app/lib/steps/run-claude-code.ts @@ -45,16 +45,14 @@ export async function runClaudeCode( const escapedApiKey = env.VERCEL_AI_GATEWAY_API_KEY.replace(/'/g, "'\\''"); // Run claude with Vercel AI Gateway env vars set inline - let result; - - try { - result = await sandbox.runCommand("sh", [ + const result = await sandbox + .runCommand("sh", [ "-c", `ANTHROPIC_BASE_URL='https://ai-gateway.vercel.sh' ANTHROPIC_AUTH_TOKEN='${escapedApiKey}' ANTHROPIC_API_KEY='' claude -p '${escapedPrompt}' --dangerously-skip-permissions --model claude-haiku-4-5 --max-turns 30 --output-format json`, - ]); - } catch (error) { - throw new Error(`Failed to run Claude Code: ${parseError(error)}`); - } + ]) + .catch((error: unknown) => { + throw new Error(`Failed to run Claude Code: ${parseError(error)}`); + }); // Get the output const output = await result.output("both"); diff --git a/bun.lock b/bun.lock index 06eea471..31a2a0c6 100644 --- a/bun.lock +++ b/bun.lock @@ -138,7 +138,7 @@ }, "packages/cli": { "name": "ultracite", - "version": "7.1.3", + "version": "7.1.4", "bin": { "ultracite": "dist/index.js", }, @@ -309,7 +309,7 @@ "@asyncapi/parser": ["@asyncapi/parser@3.4.0", "", { "dependencies": { "@asyncapi/specs": "^6.8.0", "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", "@stoplight/json": "3.21.0", "@stoplight/json-ref-readers": "^1.2.2", "@stoplight/json-ref-resolver": "^3.1.5", "@stoplight/spectral-core": "^1.18.3", "@stoplight/spectral-functions": "^1.7.2", "@stoplight/spectral-parsers": "^1.0.2", "@stoplight/spectral-ref-resolver": "^1.0.3", "@stoplight/types": "^13.12.0", "@types/json-schema": "^7.0.11", "@types/urijs": "^1.19.19", "ajv": "^8.17.1", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", "avsc": "^5.7.5", "js-yaml": "^4.1.0", "jsonpath-plus": "^10.0.0", "node-fetch": "2.6.7" } }, "sha512-Sxn74oHiZSU6+cVeZy62iPZMFMvKp4jupMFHelSICCMw1qELmUHPvuZSr+ZHDmNGgHcEpzJM5HN02kR7T4g+PQ=="], - "@asyncapi/specs": ["@asyncapi/specs@6.10.0", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-vB5oKLsdrLUORIZ5BXortZTlVyGWWMC1Nud/0LtgxQ3Yn2738HigAD6EVqScvpPsDUI/bcLVsYEXN4dtXQHVng=="], + "@asyncapi/specs": ["@asyncapi/specs@6.8.1", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-czHoAk3PeXTLR+X8IUaD+IpT+g+zUvkcgMDJVothBsan+oHN3jfcFcFUNdOPAAFoUCQN1hXF1dWuphWy05THlA=="], "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], @@ -735,25 +735,25 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - "@mintlify/cli": ["@mintlify/cli@4.0.919", "", { "dependencies": { "@inquirer/prompts": "7.9.0", "@mintlify/common": "1.0.698", "@mintlify/link-rot": "3.0.857", "@mintlify/models": "0.0.263", "@mintlify/prebuild": "1.0.834", "@mintlify/previewing": "4.0.890", "@mintlify/validation": "0.1.577", "adm-zip": "0.5.16", "chalk": "5.2.0", "color": "4.2.3", "detect-port": "1.5.1", "front-matter": "4.0.2", "fs-extra": "11.2.0", "ink": "6.3.0", "inquirer": "12.3.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.2.0", "react": "19.2.3", "semver": "7.7.2", "unist-util-visit": "5.0.0", "yargs": "17.7.1" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-7zVYxDV/KteLlphmy7zGLUIQek1jFM1Lt7DPo1LVAUR3S8gYp35MopJLkR/o7WrpVVZnyZAYdmkEs/cbiCBQBw=="], + "@mintlify/cli": ["@mintlify/cli@4.0.936", "", { "dependencies": { "@inquirer/prompts": "7.9.0", "@mintlify/common": "1.0.714", "@mintlify/link-rot": "3.0.873", "@mintlify/models": "0.0.268", "@mintlify/prebuild": "1.0.850", "@mintlify/previewing": "4.0.906", "@mintlify/validation": "0.1.585", "adm-zip": "0.5.16", "chalk": "5.2.0", "color": "4.2.3", "detect-port": "1.5.1", "front-matter": "4.0.2", "fs-extra": "11.2.0", "ink": "6.3.0", "inquirer": "12.3.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.2.0", "react": "19.2.3", "semver": "7.7.2", "unist-util-visit": "5.0.0", "yargs": "17.7.1" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-C4JPyU3dznch1+fEG72JmVAk5PbrN9yNt0SQrnt3gqrcq6ADeyoAhR/pouwGchcDtoceqz5pezFJiWfNNYUuVA=="], - "@mintlify/common": ["@mintlify/common@1.0.698", "", { "dependencies": { "@asyncapi/parser": "3.4.0", "@mintlify/mdx": "^3.0.4", "@mintlify/models": "0.0.263", "@mintlify/openapi-parser": "^0.0.8", "@mintlify/validation": "0.1.577", "@sindresorhus/slugify": "2.2.0", "@types/mdast": "4.0.4", "acorn": "8.11.2", "acorn-jsx": "5.3.2", "color-blend": "4.0.0", "estree-util-to-js": "2.0.0", "estree-walker": "3.0.3", "front-matter": "4.0.2", "hast-util-from-html": "2.0.3", "hast-util-to-html": "9.0.4", "hast-util-to-text": "4.0.2", "hex-rgb": "5.0.0", "ignore": "7.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", "mdast-util-from-markdown": "2.0.2", "mdast-util-gfm": "3.0.0", "mdast-util-mdx": "3.0.0", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-gfm": "3.0.0", "micromark-extension-mdx-jsx": "3.0.1", "micromark-extension-mdxjs": "3.0.0", "openapi-types": "12.1.3", "postcss": "8.5.6", "rehype-stringify": "10.0.1", "remark": "15.0.1", "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.0", "remark-math": "6.0.0", "remark-mdx": "3.1.0", "remark-parse": "11.0.0", "remark-rehype": "11.1.1", "remark-stringify": "11.0.0", "tailwindcss": "3.4.4", "unified": "11.0.5", "unist-builder": "4.0.0", "unist-util-map": "4.0.0", "unist-util-remove": "4.0.0", "unist-util-remove-position": "5.0.0", "unist-util-visit": "5.0.0", "unist-util-visit-parents": "6.0.1", "vfile": "6.0.3" } }, "sha512-CicjlATkONn6ARR55J6JY+tUmSof3zjZKb708RYv3yG3qQdMB2/g0+FvQXkk2dsPgq105J5dFb/n2+vYuiEsHA=="], + "@mintlify/common": ["@mintlify/common@1.0.714", "", { "dependencies": { "@asyncapi/parser": "3.4.0", "@asyncapi/specs": "6.8.1", "@mintlify/mdx": "^3.0.4", "@mintlify/models": "0.0.268", "@mintlify/openapi-parser": "^0.0.8", "@mintlify/validation": "0.1.585", "@sindresorhus/slugify": "2.2.0", "@types/mdast": "4.0.4", "acorn": "8.11.2", "acorn-jsx": "5.3.2", "color-blend": "4.0.0", "estree-util-to-js": "2.0.0", "estree-walker": "3.0.3", "front-matter": "4.0.2", "hast-util-from-html": "2.0.3", "hast-util-to-html": "9.0.4", "hast-util-to-text": "4.0.2", "hex-rgb": "5.0.0", "ignore": "7.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", "mdast-util-from-markdown": "2.0.2", "mdast-util-gfm": "3.0.0", "mdast-util-mdx": "3.0.0", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-gfm": "3.0.0", "micromark-extension-mdx-jsx": "3.0.1", "micromark-extension-mdxjs": "3.0.0", "openapi-types": "12.1.3", "postcss": "8.5.6", "rehype-stringify": "10.0.1", "remark": "15.0.1", "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.0", "remark-math": "6.0.0", "remark-mdx": "3.1.0", "remark-parse": "11.0.0", "remark-rehype": "11.1.1", "remark-stringify": "11.0.0", "tailwindcss": "3.4.4", "unified": "11.0.5", "unist-builder": "4.0.0", "unist-util-map": "4.0.0", "unist-util-remove": "4.0.0", "unist-util-remove-position": "5.0.0", "unist-util-visit": "5.0.0", "unist-util-visit-parents": "6.0.1", "vfile": "6.0.3" } }, "sha512-mYEzJ6oee5bpibKrTX13Y3IVdDB54BvP+MVpniJ/2WiSJP3mE6KWBAkaYzjt/5V8/w7nwoHLWLAvu8ks9I8a/w=="], - "@mintlify/link-rot": ["@mintlify/link-rot@3.0.857", "", { "dependencies": { "@mintlify/common": "1.0.698", "@mintlify/prebuild": "1.0.834", "@mintlify/previewing": "4.0.890", "@mintlify/scraping": "4.0.522", "@mintlify/validation": "0.1.577", "fs-extra": "11.1.0", "unist-util-visit": "4.1.2" } }, "sha512-ThNzzt4qzs0G88l7VJ3GqTHRUGhtDftydWQrxvm2v/eTUO9mjFkJZ1UZcp0/NYZgEOi2eLLHSns5B+PVlow5LA=="], + "@mintlify/link-rot": ["@mintlify/link-rot@3.0.873", "", { "dependencies": { "@mintlify/common": "1.0.714", "@mintlify/prebuild": "1.0.850", "@mintlify/previewing": "4.0.906", "@mintlify/scraping": "4.0.522", "@mintlify/validation": "0.1.585", "fs-extra": "11.1.0", "unist-util-visit": "4.1.2" } }, "sha512-3BFlm8s+YniP+mZlxFEIGYkbrtTaJdthS4J+X6I80bPPnOmnezDq2nMOWMF47OYIN/eHId3RRjesb/fhxqGlpw=="], "@mintlify/mdx": ["@mintlify/mdx@3.0.4", "", { "dependencies": { "@shikijs/transformers": "^3.11.0", "@shikijs/twoslash": "^3.12.2", "arktype": "^2.1.26", "hast-util-to-string": "^3.0.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^3.1.0", "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-hast": "^13.2.0", "next-mdx-remote-client": "^1.0.3", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-smartypants": "^3.0.2", "shiki": "^3.11.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@radix-ui/react-popover": "^1.1.15", "react": "^18.3.1", "react-dom": "^18.3.1" } }, "sha512-tJhdpnM5ReJLNJ2fuDRIEr0zgVd6id7/oAIfs26V46QlygiLsc8qx4Rz3LWIX51rUXW/cfakjj0EATxIciIw+g=="], - "@mintlify/models": ["@mintlify/models@0.0.263", "", { "dependencies": { "axios": "1.13.2", "openapi-types": "12.1.3" } }, "sha512-Hfu0CfCkZ0Rpsvc5CBX3JDA0bqDWJ16T7ukoj/y5KltWhrukEPOl9/QR1zG/ScdXDwdOm3Zn5QQDT3GLbL0tnQ=="], + "@mintlify/models": ["@mintlify/models@0.0.268", "", { "dependencies": { "axios": "1.13.2", "openapi-types": "12.1.3" } }, "sha512-8HDPI3luABg5p/VTVYAOqabqOtcK2jdBuRTYOJiV39QqjQY29Q7kWH697PUokN6CO9uP2CCkPG5O5Gi7QxflWA=="], "@mintlify/openapi-parser": ["@mintlify/openapi-parser@0.0.8", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-9MBRq9lS4l4HITYCrqCL7T61MOb20q9IdU7HWhqYMNMM1jGO1nHjXasFy61yZ8V6gMZyyKQARGVoZ0ZrYN48Og=="], - "@mintlify/prebuild": ["@mintlify/prebuild@1.0.834", "", { "dependencies": { "@mintlify/common": "1.0.698", "@mintlify/openapi-parser": "^0.0.8", "@mintlify/scraping": "4.0.559", "@mintlify/validation": "0.1.577", "chalk": "5.3.0", "favicons": "7.2.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "js-yaml": "4.1.0", "openapi-types": "12.1.3", "sharp": "0.33.5", "sharp-ico": "0.1.5", "unist-util-visit": "4.1.2", "uuid": "11.1.0" } }, "sha512-JEdLMiKp9AygNpyEP2OuBJi9dzH34o8o57E55vlUrhQ6I/Po3Ww0yNQYH5wQ6bZM+tVX2ugJjKMclFURBfYMaw=="], + "@mintlify/prebuild": ["@mintlify/prebuild@1.0.850", "", { "dependencies": { "@mintlify/common": "1.0.714", "@mintlify/openapi-parser": "^0.0.8", "@mintlify/scraping": "4.0.575", "@mintlify/validation": "0.1.585", "chalk": "5.3.0", "favicons": "7.2.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "js-yaml": "4.1.0", "openapi-types": "12.1.3", "sharp": "0.33.5", "sharp-ico": "0.1.5", "unist-util-visit": "4.1.2", "uuid": "11.1.0" } }, "sha512-TKRTbFMSJ0oi1iBdS9jNK+avI92aQxnd6IEjoDa6Ef9CyrXr8eNYMIiTEVGp0DK6rbOLuvUD/f4pkYEeNPRizg=="], - "@mintlify/previewing": ["@mintlify/previewing@4.0.890", "", { "dependencies": { "@mintlify/common": "1.0.698", "@mintlify/prebuild": "1.0.834", "@mintlify/validation": "0.1.577", "better-opn": "3.0.2", "chalk": "5.2.0", "chokidar": "3.5.3", "express": "4.18.2", "front-matter": "4.0.2", "fs-extra": "11.1.0", "got": "13.0.0", "ink": "6.3.0", "ink-spinner": "5.0.0", "is-online": "10.0.0", "js-yaml": "4.1.0", "openapi-types": "12.1.3", "react": "19.2.3", "socket.io": "4.7.2", "tar": "6.1.15", "unist-util-visit": "4.1.2", "yargs": "17.7.1" } }, "sha512-A8QeRFQ9dCsZJQgr6GCzSSrI+zlNm4dSlYUBDjPDkYI92uKmZO95maAKiXaWPKGQfjeIf8YYbP3n+k4QE+gpgg=="], + "@mintlify/previewing": ["@mintlify/previewing@4.0.906", "", { "dependencies": { "@mintlify/common": "1.0.714", "@mintlify/prebuild": "1.0.850", "@mintlify/validation": "0.1.585", "better-opn": "3.0.2", "chalk": "5.2.0", "chokidar": "3.5.3", "express": "4.18.2", "front-matter": "4.0.2", "fs-extra": "11.1.0", "got": "13.0.0", "ink": "6.3.0", "ink-spinner": "5.0.0", "is-online": "10.0.0", "js-yaml": "4.1.0", "openapi-types": "12.1.3", "react": "19.2.3", "socket.io": "4.7.2", "tar": "6.1.15", "unist-util-visit": "4.1.2", "yargs": "17.7.1" } }, "sha512-2Ts5WuvcOrlM+LP+Vwc9tzesVwoqYavYUiQYBnKAjU+Wq9z8td6d6tjeVCI743YIQ0zFqMZkDx1k1TI2eVkUTw=="], "@mintlify/scraping": ["@mintlify/scraping@4.0.522", "", { "dependencies": { "@mintlify/common": "1.0.661", "@mintlify/openapi-parser": "^0.0.8", "fs-extra": "11.1.1", "hast-util-to-mdast": "10.1.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.1.3", "neotraverse": "0.6.18", "puppeteer": "22.14.0", "rehype-parse": "9.0.1", "remark-gfm": "4.0.0", "remark-mdx": "3.0.1", "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "unified": "11.0.5", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "3.21.4" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-PL2k52WT5S5OAgnT2K13bP7J2El6XwiVvQlrLvxDYw5KMMV+y34YVJI8ZscKb4trjitWDgyK0UTq2KN6NQgn6g=="], - "@mintlify/validation": ["@mintlify/validation@0.1.577", "", { "dependencies": { "@mintlify/mdx": "^3.0.4", "@mintlify/models": "0.0.263", "arktype": "2.1.27", "js-yaml": "4.1.0", "lcm": "0.0.3", "lodash": "4.17.21", "object-hash": "3.0.0", "openapi-types": "12.1.3", "uuid": "11.1.0", "zod": "3.24.0", "zod-to-json-schema": "3.20.4" } }, "sha512-tecysj9oeTc0SHz1ro/oaqMLwEpJw/K8oqoDWULgOfBcDPeG6uKNMe2NiLyVZLZUMxsywFKOJFRkF/8mTbJcHQ=="], + "@mintlify/validation": ["@mintlify/validation@0.1.585", "", { "dependencies": { "@mintlify/mdx": "^3.0.4", "@mintlify/models": "0.0.268", "arktype": "2.1.27", "js-yaml": "4.1.0", "lcm": "0.0.3", "lodash": "4.17.21", "object-hash": "3.0.0", "openapi-types": "12.1.3", "uuid": "11.1.0", "zod": "3.24.0", "zod-to-json-schema": "3.20.4" } }, "sha512-32mezT7v1dmPQa2DyGDYf0t+HHUbmpShJVnMrxxhXyMHvKUqOu4ENoRCAxRbfc4OLPxAFRB4qEEq2toID+tOHw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], @@ -1103,15 +1103,15 @@ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA=="], - "@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="], + "@shikijs/langs": ["@shikijs/langs@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA=="], - "@shikijs/themes": ["@shikijs/themes@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw=="], + "@shikijs/themes": ["@shikijs/themes@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g=="], "@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@shikijs/twoslash": ["@shikijs/twoslash@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/types": "3.21.0", "twoslash": "^0.3.6" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-iH360udAYON2JwfIldoCiMZr9MljuQA5QRBivKLpEuEpmVCSwrR+0WTQ0eS1ptgGBdH9weFiIsA5wJDzsEzTYg=="], - "@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="], + "@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -2983,7 +2983,7 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - "mint": ["mint@4.2.315", "", { "dependencies": { "@mintlify/cli": "4.0.919" }, "bin": { "mint": "index.js", "mintlify": "index.js" } }, "sha512-lw30b25ikOw5VERMiNngitDfbX3VECrWCm/y8GNSoOX+IMjhXhT+wre1ZLgpT6277wlsIrDAFvWranwRLfyYwQ=="], + "mint": ["mint@4.2.332", "", { "dependencies": { "@mintlify/cli": "4.0.936" }, "bin": { "mint": "index.js", "mintlify": "index.js" } }, "sha512-3+fjX4+rFZm4yGgmsyqLEG+FBCTiMZ/rD1sG2Ak39K73aIvVk8ziakVpKKYF5sbcHWbaV8d+H7IP99nBuFfoig=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], @@ -3983,6 +3983,8 @@ "@antfu/ni/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "@asyncapi/parser/@asyncapi/specs": ["@asyncapi/specs@6.10.0", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-vB5oKLsdrLUORIZ5BXortZTlVyGWWMC1Nud/0LtgxQ3Yn2738HigAD6EVqScvpPsDUI/bcLVsYEXN4dtXQHVng=="], + "@asyncapi/parser/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], @@ -4183,7 +4185,7 @@ "@mintlify/openapi-parser/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], - "@mintlify/prebuild/@mintlify/scraping": ["@mintlify/scraping@4.0.559", "", { "dependencies": { "@mintlify/common": "1.0.698", "@mintlify/openapi-parser": "^0.0.8", "fs-extra": "11.1.1", "hast-util-to-mdast": "10.1.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.1.3", "neotraverse": "0.6.18", "puppeteer": "22.14.0", "rehype-parse": "9.0.1", "remark-gfm": "4.0.0", "remark-mdx": "3.0.1", "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "unified": "11.0.5", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "3.24.0" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-xowugtpLPQacXLqdSB+X85Ug1uxbJMkWa8IzO8CiyJN9kcx1EIG9Ydxn0JJhyENETR3jd1VLKzIMvLXFeGAzZA=="], + "@mintlify/prebuild/@mintlify/scraping": ["@mintlify/scraping@4.0.575", "", { "dependencies": { "@mintlify/common": "1.0.714", "@mintlify/openapi-parser": "^0.0.8", "fs-extra": "11.1.1", "hast-util-to-mdast": "10.1.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.1.3", "neotraverse": "0.6.18", "puppeteer": "22.14.0", "rehype-parse": "9.0.1", "remark-gfm": "4.0.0", "remark-mdx": "3.0.1", "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "unified": "11.0.5", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "3.24.0" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-zM13esZGbMZpkXQDxknK9nedooVIhVboHZouXCOcOcPrzteCOJhgb4jYkuqD1lEhNWyvQGT7PMtIN4rQhs5HgA=="], "@mintlify/prebuild/chalk": ["chalk@5.3.0", "", {}, "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w=="], @@ -4335,18 +4337,14 @@ "@repo/design-system/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], - "@shikijs/core/@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], - - "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], - - "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], - "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/twoslash/@shikijs/core": ["@shikijs/core@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA=="], + "@shikijs/twoslash/@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="], + "@sindresorhus/slugify/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "@sindresorhus/transliterate/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -4841,12 +4839,6 @@ "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "shiki/@shikijs/langs": ["@shikijs/langs@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA=="], - - "shiki/@shikijs/themes": ["@shikijs/themes@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g=="], - - "shiki/@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], - "simple-swizzle/is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -4987,6 +4979,12 @@ "@mintlify/mdx/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="], + "@mintlify/mdx/shiki/@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="], + + "@mintlify/mdx/shiki/@shikijs/themes": ["@shikijs/themes@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw=="], + + "@mintlify/mdx/shiki/@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="], + "@mintlify/prebuild/@mintlify/scraping/fs-extra": ["fs-extra@11.1.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ=="], "@mintlify/prebuild/@mintlify/scraping/mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.1.3", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ=="], diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index be823b2f..5bedaac3 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -39,6 +39,11 @@ model Organization { repos Repo[] lintRuns LintRun[] + // Referrals + referralCode ReferralCode? + referralsGiven Referral[] @relation("ReferrerOrganization") + referralReceived Referral? @relation("ReferredOrganization") + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -127,3 +132,37 @@ enum LintRunStatus { FAILED SKIPPED } + +model ReferralCode { + id String @id @default(cuid()) + code String @unique // 8-char alphanumeric + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String @unique // One code per org + timesUsed Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([code]) +} + +model Referral { + id String @id @default(cuid()) + referrerOrganization Organization @relation("ReferrerOrganization", fields: [referrerOrganizationId], references: [id], onDelete: Cascade) + referrerOrganizationId String + referredOrganization Organization @relation("ReferredOrganization", fields: [referredOrganizationId], references: [id], onDelete: Cascade) + referredOrganizationId String @unique // Each org referred once + status ReferralStatus @default(PENDING) + referrerCreditedAt DateTime? + referredCreditedAt DateTime? + paidInvoiceId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([referrerOrganizationId]) +} + +enum ReferralStatus { + PENDING // Waiting for first paid invoice + COMPLETED // Both parties credited + INVALID // Fraud/abuse +} diff --git a/packages/cli/__tests__/check.test.ts b/packages/cli/__tests__/check.test.ts index bb224b5e..16f5d05d 100644 --- a/packages/cli/__tests__/check.test.ts +++ b/packages/cli/__tests__/check.test.ts @@ -114,7 +114,7 @@ describe("check", () => { test("exits with status code when biome check finds errors", async () => { const mockSpawn = mock(() => ({ status: 1 })); - const mockExit = mock(() => {}); + const mockExit = mock(() => undefined); mock.module("node:child_process", () => ({ spawnSync: mockSpawn, @@ -415,7 +415,7 @@ describe("check", () => { test("eslint check exits with status code on failure", async () => { const mockSpawn = mock(() => ({ status: 1 })); - const mockExit = mock(() => {}); + const mockExit = mock(() => undefined); mock.module("node:child_process", () => ({ spawnSync: mockSpawn, @@ -440,7 +440,7 @@ describe("check", () => { test("oxlint check exits with status code on failure", async () => { const mockSpawn = mock(() => ({ status: 1 })); - const mockExit = mock(() => {}); + const mockExit = mock(() => undefined); mock.module("node:child_process", () => ({ spawnSync: mockSpawn, diff --git a/packages/cli/__tests__/fix.test.ts b/packages/cli/__tests__/fix.test.ts index 9ffc9366..97d7b213 100644 --- a/packages/cli/__tests__/fix.test.ts +++ b/packages/cli/__tests__/fix.test.ts @@ -139,7 +139,7 @@ describe("fix", () => { test("exits with status code when biome fix finds errors", async () => { const mockSpawn = mock(() => ({ status: 1 })); - const mockExit = mock(() => {}); + const mockExit = mock(() => undefined); mock.module("node:child_process", () => ({ spawnSync: mockSpawn, @@ -267,7 +267,7 @@ describe("fix", () => { test("eslint fix exits with status code when prettier fails", async () => { const mockSpawn = mock(() => ({ status: 1 })); - const mockExit = mock(() => {}); + const mockExit = mock(() => undefined); mock.module("node:child_process", () => ({ spawnSync: mockSpawn, @@ -468,7 +468,7 @@ describe("fix", () => { test("oxlint fix exits with status code on failure", async () => { const mockSpawn = mock(() => ({ status: 1 })); - const mockExit = mock(() => {}); + const mockExit = mock(() => undefined); mock.module("node:child_process", () => ({ spawnSync: mockSpawn, diff --git a/packages/cli/__tests__/initialize.test.ts b/packages/cli/__tests__/initialize.test.ts index 37c3ccc2..2400aeb9 100644 --- a/packages/cli/__tests__/initialize.test.ts +++ b/packages/cli/__tests__/initialize.test.ts @@ -1024,7 +1024,7 @@ describe("initialize", () => { agents: [], integrations: [], frameworks: [], - }); + }); }).toThrow("Install failed"); expect(mockLog.error).toHaveBeenCalled(); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 55d1d5d2..59334376 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -53,7 +53,9 @@ program const allArgs = process.argv.slice(cmdIndex + 1); const passthrough = allArgs.filter((arg) => arg.startsWith("-")); // Filter out any flags that Commander may have included in files - const filteredFiles = files.filter((file: string) => !file.startsWith("--")); + const filteredFiles = files.filter( + (file: string) => !file.startsWith("--") + ); await check(filteredFiles, passthrough); }); @@ -69,7 +71,9 @@ program const allArgs = process.argv.slice(cmdIndex + 1); const passthrough = allArgs.filter((arg) => arg.startsWith("-")); // Filter out any flags that Commander may have included in files - const filteredFiles = files.filter((file: string) => !file.startsWith("--")); + const filteredFiles = files.filter( + (file: string) => !file.startsWith("--") + ); await fix(filteredFiles, passthrough); });