diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..2d53e036 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# App +VITE_CONVEX_URL= +VITE_CONVEX_SITE_URL= +VITE_CLERK_PUBLISHABLE_KEY= + +# Convex / Clerk +CONVEX_DEPLOYMENT= +CONVEX_DEPLOY_KEY= +CLERK_SECRET_KEY= +CLERK_JWT_ISSUER_DOMAIN= + +# Mux +MUX_TOKEN_ID= +MUX_TOKEN_SECRET= +MUX_WEBHOOK_SECRET= +MUX_SIGNING_KEY= +MUX_PRIVATE_KEY= +# Legacy Mux signing aliases, only needed if the app is configured to use them. +# MUX_SIGNING_KEY_ID= +# MUX_SIGNING_PRIVATE_KEY= + +# Stripe +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_BASIC_MONTHLY= +STRIPE_PRICE_PRO_MONTHLY= + +# Railway object storage +RAILWAY_ACCESS_KEY_ID= +RAILWAY_SECRET_ACCESS_KEY= +RAILWAY_ENDPOINT= +RAILWAY_PUBLIC_URL= +RAILWAY_PUBLIC_URL_INCLUDE_BUCKET=true +RAILWAY_BUCKET_NAME=videos +RAILWAY_REGION=us-east-1 diff --git a/.gitignore b/.gitignore index 94b327d9..1c806a2a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..4bc5e1d9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "window.titleBarStyle": "custom", + "workbench.colorCustomizations": { + "titleBar.activeBackground": "#2d5a2d", + "titleBar.activeForeground": "#ffffff", + "titleBar.inactiveBackground": "#3a6a3a", + "titleBar.inactiveForeground": "#ffffff" + } +} diff --git a/app/routes/dashboard/-useVideoUploadManager.ts b/app/routes/dashboard/-useVideoUploadManager.ts deleted file mode 100644 index cfb760d4..00000000 --- a/app/routes/dashboard/-useVideoUploadManager.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { useAction, useMutation } from "convex/react"; -import { useCallback, useState } from "react"; -import { api } from "@convex/_generated/api"; -import { Id } from "@convex/_generated/dataModel"; -import type { UploadStatus } from "@/components/upload/UploadProgress"; - -export interface ManagedUploadItem { - id: string; - projectId: Id<"projects">; - file: File; - videoId?: Id<"videos">; - progress: number; - status: UploadStatus; - error?: string; - bytesPerSecond?: number; - estimatedSecondsRemaining?: number | null; - abortController?: AbortController; -} - -function createUploadId() { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - return Math.random().toString(36).slice(2); -} - -export function useVideoUploadManager() { - const createVideo = useMutation(api.videos.create); - const getUploadUrl = useAction(api.videoActions.getUploadUrl); - const markUploadComplete = useAction(api.videoActions.markUploadComplete); - const markUploadFailed = useAction(api.videoActions.markUploadFailed); - const [uploads, setUploads] = useState([]); - - const uploadFilesToProject = useCallback( - async (projectId: Id<"projects">, files: File[]) => { - for (const file of files) { - const uploadId = createUploadId(); - const title = file.name.replace(/\.[^/.]+$/, ""); - const abortController = new AbortController(); - - setUploads((prev) => [ - ...prev, - { - id: uploadId, - projectId, - file, - progress: 0, - status: "pending", - abortController, - }, - ]); - - let createdVideoId: Id<"videos"> | undefined; - - try { - createdVideoId = await createVideo({ - projectId, - title, - fileSize: file.size, - contentType: file.type || "video/mp4", - }); - - setUploads((prev) => - prev.map((upload) => - upload.id === uploadId - ? { ...upload, videoId: createdVideoId, status: "uploading" } - : upload, - ), - ); - - const { url } = await getUploadUrl({ - videoId: createdVideoId, - filename: file.name, - fileSize: file.size, - contentType: file.type || "video/mp4", - }); - - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - let lastTime = Date.now(); - let lastLoaded = 0; - const recentSpeeds: number[] = []; - - xhr.upload.addEventListener("progress", (event) => { - if (!event.lengthComputable) return; - - const percentage = Math.round((event.loaded / event.total) * 100); - const now = Date.now(); - const timeDelta = (now - lastTime) / 1000; - const bytesDelta = event.loaded - lastLoaded; - - if (timeDelta > 0.1) { - const speed = bytesDelta / timeDelta; - recentSpeeds.push(speed); - if (recentSpeeds.length > 5) recentSpeeds.shift(); - lastTime = now; - lastLoaded = event.loaded; - } - - const avgSpeed = - recentSpeeds.length > 0 - ? recentSpeeds.reduce((sum, speed) => sum + speed, 0) / - recentSpeeds.length - : 0; - const remaining = event.total - event.loaded; - const eta = avgSpeed > 0 ? Math.ceil(remaining / avgSpeed) : null; - - setUploads((prev) => - prev.map((upload) => - upload.id === uploadId - ? { - ...upload, - progress: percentage, - bytesPerSecond: avgSpeed, - estimatedSecondsRemaining: eta, - } - : upload, - ), - ); - }); - - xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - return; - } - reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)); - }); - - xhr.addEventListener("error", () => { - reject(new Error("Upload failed: Network error")); - }); - - xhr.addEventListener("abort", () => { - reject(new Error("Upload cancelled")); - }); - - abortController.signal.addEventListener("abort", () => { - xhr.abort(); - }); - - xhr.open("PUT", url); - xhr.setRequestHeader("Content-Type", file.type || "video/mp4"); - xhr.send(file); - }); - - await markUploadComplete({ videoId: createdVideoId }); - - setUploads((prev) => - prev.map((upload) => - upload.id === uploadId - ? { ...upload, status: "complete", progress: 100 } - : upload, - ), - ); - - setTimeout(() => { - setUploads((prev) => prev.filter((upload) => upload.id !== uploadId)); - }, 3000); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Upload failed"; - - setUploads((prev) => - prev.map((upload) => - upload.id === uploadId - ? { ...upload, status: "error", error: errorMessage } - : upload, - ), - ); - - if (createdVideoId) { - markUploadFailed({ videoId: createdVideoId }).catch(console.error); - } - } - } - }, - [createVideo, getUploadUrl, markUploadComplete, markUploadFailed], - ); - - const cancelUpload = useCallback( - (uploadId: string) => { - const upload = uploads.find((item) => item.id === uploadId); - if (upload?.abortController) { - upload.abortController.abort(); - } - if (upload?.videoId) { - markUploadFailed({ videoId: upload.videoId }).catch(console.error); - } - setUploads((prev) => prev.filter((item) => item.id !== uploadId)); - }, - [uploads, markUploadFailed], - ); - - return { - uploads, - uploadFilesToProject, - cancelUpload, - }; -} diff --git a/bun.lock b/bun.lock index 60b97dd1..1266cf4c 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@stripe/stripe-js": "^8.7.0", + "@t3-oss/env-core": "^0.13.11", "@tanstack/react-router": "^1.160.2", "@tanstack/react-router-devtools": "^1.160.2", "@tanstack/react-start": "^1.160.2", @@ -598,6 +599,8 @@ "@stripe/stripe-js": ["@stripe/stripe-js@8.7.0", "", {}, "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ=="], + "@t3-oss/env-core": ["@t3-oss/env-core@0.13.11", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], diff --git a/convex/auth.config.ts b/convex/auth.config.ts index 6caa3b7a..808c5e15 100644 --- a/convex/auth.config.ts +++ b/convex/auth.config.ts @@ -1,7 +1,9 @@ +import { env } from "./env"; + export default { providers: [ { - domain: process.env.CLERK_JWT_ISSUER_DOMAIN, + domain: env.CLERK_JWT_ISSUER_DOMAIN, applicationID: "convex", }, ], diff --git a/convex/billingHelpers.ts b/convex/billingHelpers.ts index 6c85aecf..4839958e 100644 --- a/convex/billingHelpers.ts +++ b/convex/billingHelpers.ts @@ -1,10 +1,12 @@ import { components } from "./_generated/api"; import { Id } from "./_generated/dataModel"; import { MutationCtx, QueryCtx } from "./_generated/server"; +import { env, getStripePriceIdForEnvVar } from "./env"; export type TeamPlan = "basic" | "pro"; const GIBIBYTE = 1024 ** 3; +const TEBIBYTE = 1024 ** 4; export const TEAM_PLAN_MONTHLY_PRICE_USD: Record = { basic: 5, @@ -13,13 +15,33 @@ export const TEAM_PLAN_MONTHLY_PRICE_USD: Record = { export const TEAM_PLAN_STORAGE_LIMIT_BYTES: Record = { basic: 100 * GIBIBYTE, - pro: 1024 * GIBIBYTE, + pro: TEBIBYTE, +}; + +export const TEAM_PLAN_MAX_FILE_SIZE_BYTES: Record = { + basic: 10 * GIBIBYTE, + pro: 50 * GIBIBYTE, }; function hasText(value: string | undefined | null): value is string { return typeof value === "string" && value.trim().length > 0; } +export function formatPlanLabel(plan: TeamPlan) { + return plan === "basic" ? "Basic" : "Pro"; +} + +export function formatBytesForBilling(bytes: number) { + const safeBytes = Number.isFinite(bytes) ? Math.max(0, bytes) : 0; + if (safeBytes >= TEBIBYTE) { + const value = safeBytes / TEBIBYTE; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} TB`; + } + + const value = safeBytes / GIBIBYTE; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} GB`; +} + export function normalizeStoredTeamPlan(plan: string): TeamPlan { if (plan === "pro" || plan === "team") return "pro"; return "basic"; @@ -30,8 +52,8 @@ export function resolvePlanFromStripePriceId( ): TeamPlan | null { if (!hasText(stripePriceId)) return null; - const basicPriceId = process.env.STRIPE_PRICE_BASIC_MONTHLY; - const proPriceId = process.env.STRIPE_PRICE_PRO_MONTHLY; + const basicPriceId = env.STRIPE_PRICE_BASIC_MONTHLY; + const proPriceId = env.STRIPE_PRICE_PRO_MONTHLY; if (hasText(basicPriceId) && stripePriceId === basicPriceId) return "basic"; if (hasText(proPriceId) && stripePriceId === proPriceId) return "pro"; @@ -41,11 +63,7 @@ export function resolvePlanFromStripePriceId( export function getStripePriceIdForPlan(plan: TeamPlan): string { const variableName = plan === "basic" ? "STRIPE_PRICE_BASIC_MONTHLY" : "STRIPE_PRICE_PRO_MONTHLY"; - const value = process.env[variableName]; - if (!hasText(value)) { - throw new Error(`${variableName} is not configured`); - } - return value; + return getStripePriceIdForEnvVar(variableName); } export function hasActiveTeamSubscriptionStatus( @@ -137,8 +155,13 @@ export async function assertTeamCanStoreBytes( const requestedBytes = Number.isFinite(incomingBytes) ? Math.max(0, incomingBytes) : 0; if (storageUsedBytes + requestedBytes > storageLimitBytes) { + const planLabel = formatPlanLabel(state.plan); + const actionCopy = + state.plan === "basic" + ? "Upgrade to Pro or delete old videos to free up space." + : "Delete old videos to free up space."; throw new Error( - `Storage limit reached for the ${state.plan} plan. Upgrade to continue uploading.`, + `This upload would exceed your team's ${planLabel} plan storage limit. You're using ${formatBytesForBilling(storageUsedBytes)} of ${formatBytesForBilling(storageLimitBytes)}, and this file is ${formatBytesForBilling(requestedBytes)}. ${actionCopy}`, ); } @@ -148,3 +171,33 @@ export async function assertTeamCanStoreBytes( storageLimitBytes, }; } + +export async function assertTeamCanUploadFileBytes( + ctx: BillingCtx, + teamId: Id<"teams">, + fileSizeBytes: number, +) { + const state = await assertTeamHasActiveSubscription(ctx, teamId); + const requestedBytes = Number.isFinite(fileSizeBytes) + ? Math.max(0, fileSizeBytes) + : 0; + const maxFileSizeBytes = TEAM_PLAN_MAX_FILE_SIZE_BYTES[state.plan]; + + if (requestedBytes > maxFileSizeBytes) { + const planLabel = formatPlanLabel(state.plan); + const maxFileSizeLabel = formatBytesForBilling(maxFileSizeBytes); + const upgradeCopy = + state.plan === "basic" + ? " Upgrade to Pro for files up to 50 GB." + : ""; + + throw new Error( + `This file is too large for the ${planLabel} plan. ${planLabel} supports files up to ${maxFileSizeLabel}.${upgradeCopy}`, + ); + } + + return { + ...state, + maxFileSizeBytes, + }; +} diff --git a/convex/env.ts b/convex/env.ts new file mode 100644 index 00000000..2ff470aa --- /dev/null +++ b/convex/env.ts @@ -0,0 +1,97 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; + +const requiredString = z.string().min(1); + +const server = { + CLERK_JWT_ISSUER_DOMAIN: requiredString, + MUX_TOKEN_ID: requiredString, + MUX_TOKEN_SECRET: requiredString, + MUX_WEBHOOK_SECRET: requiredString, + MUX_SIGNING_KEY: requiredString.optional(), + MUX_PRIVATE_KEY: requiredString.optional(), + MUX_SIGNING_KEY_ID: requiredString.optional(), + MUX_SIGNING_PRIVATE_KEY: requiredString.optional(), + RAILWAY_ACCESS_KEY_ID: requiredString, + RAILWAY_SECRET_ACCESS_KEY: requiredString, + RAILWAY_ENDPOINT: requiredString.url(), + RAILWAY_PUBLIC_URL: requiredString.url().optional(), + RAILWAY_PUBLIC_URL_INCLUDE_BUCKET: z.enum(["true", "false"]).default("true"), + RAILWAY_BUCKET_NAME: requiredString.default("videos"), + RAILWAY_REGION: requiredString.default("us-east-1"), + STRIPE_PRICE_BASIC_MONTHLY: requiredString, + STRIPE_PRICE_PRO_MONTHLY: requiredString, +}; + +function requireEnvValue(name: string, value: string | undefined) { + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function requireFirstEnvValue( + primaryName: string, + primaryValue: string | undefined, + legacyName: string, + legacyValue: string | undefined, +) { + if (primaryValue) return primaryValue; + if (legacyValue) return legacyValue; + throw new Error( + `Missing required environment variable: ${primaryName} (or legacy ${legacyName})`, + ); +} + +export const env = createEnv({ + server, + runtimeEnvStrict: { + CLERK_JWT_ISSUER_DOMAIN: process.env.CLERK_JWT_ISSUER_DOMAIN, + MUX_TOKEN_ID: process.env.MUX_TOKEN_ID, + MUX_TOKEN_SECRET: process.env.MUX_TOKEN_SECRET, + MUX_WEBHOOK_SECRET: process.env.MUX_WEBHOOK_SECRET, + MUX_SIGNING_KEY: process.env.MUX_SIGNING_KEY, + MUX_PRIVATE_KEY: process.env.MUX_PRIVATE_KEY, + MUX_SIGNING_KEY_ID: process.env.MUX_SIGNING_KEY_ID, + MUX_SIGNING_PRIVATE_KEY: process.env.MUX_SIGNING_PRIVATE_KEY, + RAILWAY_ACCESS_KEY_ID: process.env.RAILWAY_ACCESS_KEY_ID, + RAILWAY_SECRET_ACCESS_KEY: process.env.RAILWAY_SECRET_ACCESS_KEY, + RAILWAY_ENDPOINT: process.env.RAILWAY_ENDPOINT, + RAILWAY_PUBLIC_URL: process.env.RAILWAY_PUBLIC_URL, + RAILWAY_PUBLIC_URL_INCLUDE_BUCKET: + process.env.RAILWAY_PUBLIC_URL_INCLUDE_BUCKET, + RAILWAY_BUCKET_NAME: process.env.RAILWAY_BUCKET_NAME, + RAILWAY_REGION: process.env.RAILWAY_REGION, + STRIPE_PRICE_BASIC_MONTHLY: process.env.STRIPE_PRICE_BASIC_MONTHLY, + STRIPE_PRICE_PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY, + }, + emptyStringAsUndefined: true, +}); + +export function getMuxSigningKey() { + return requireFirstEnvValue( + "MUX_SIGNING_KEY", + env.MUX_SIGNING_KEY, + "MUX_SIGNING_KEY_ID", + env.MUX_SIGNING_KEY_ID, + ); +} + +export function getMuxPrivateKey() { + return requireFirstEnvValue( + "MUX_PRIVATE_KEY", + env.MUX_PRIVATE_KEY, + "MUX_SIGNING_PRIVATE_KEY", + env.MUX_SIGNING_PRIVATE_KEY, + ); +} + +export function getRailwayPublicUrl() { + return env.RAILWAY_PUBLIC_URL ?? env.RAILWAY_ENDPOINT; +} + +export function getStripePriceIdForEnvVar( + variableName: "STRIPE_PRICE_BASIC_MONTHLY" | "STRIPE_PRICE_PRO_MONTHLY", +) { + return requireEnvValue(variableName, env[variableName]); +} diff --git a/convex/mux.ts b/convex/mux.ts index 99383bee..a2a03107 100644 --- a/convex/mux.ts +++ b/convex/mux.ts @@ -1,51 +1,17 @@ "use node"; import Mux from "@mux/mux-node"; - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Missing required environment variable: ${name}`); - } - return value; -} - -function readEnv(...names: string[]): string | null { - for (const name of names) { - const value = process.env[name]; - if (value) { - return value; - } - } - return null; -} +import { env, getMuxPrivateKey, getMuxSigningKey } from "./env"; function normalizePrivateKey(value: string): string { return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value; } function getMuxJwtCredentials(): { keyId: string; keySecret: string } { - const keyId = readEnv( - "MUX_SIGNING_KEY", - "MUX_SIGNING_KEY_ID", - ); - if (!keyId) { - throw new Error( - "Missing required environment variable: MUX_SIGNING_KEY (or legacy MUX_SIGNING_KEY_ID)", - ); - } - - const keySecret = readEnv( - "MUX_PRIVATE_KEY", - "MUX_SIGNING_PRIVATE_KEY", - ); - if (!keySecret) { - throw new Error( - "Missing required environment variable: MUX_PRIVATE_KEY (or legacy MUX_SIGNING_PRIVATE_KEY)", - ); - } - - return { keyId, keySecret: normalizePrivateKey(keySecret) }; + return { + keyId: getMuxSigningKey(), + keySecret: normalizePrivateKey(getMuxPrivateKey()), + }; } let cachedMux: Mux | null = null; @@ -54,8 +20,8 @@ export function getMuxClient(): Mux { if (cachedMux) return cachedMux; cachedMux = new Mux({ - tokenId: requireEnv("MUX_TOKEN_ID"), - tokenSecret: requireEnv("MUX_TOKEN_SECRET"), + tokenId: env.MUX_TOKEN_ID, + tokenSecret: env.MUX_TOKEN_SECRET, }); return cachedMux; @@ -148,9 +114,7 @@ export function verifyMuxWebhookSignature(rawBody: string, signature: string | n } const mux = getMuxClient(); - const webhookSecret = requireEnv("MUX_WEBHOOK_SECRET"); - mux.webhooks.verifySignature(rawBody, { "mux-signature": signature, - }, webhookSecret); + }, env.MUX_WEBHOOK_SECRET); } diff --git a/convex/s3.ts b/convex/s3.ts index 3970d471..8923527a 100644 --- a/convex/s3.ts +++ b/convex/s3.ts @@ -1,17 +1,14 @@ import { S3Client } from "@aws-sdk/client-s3"; +import { env, getRailwayPublicUrl } from "./env"; -export const BUCKET_NAME = process.env.RAILWAY_BUCKET_NAME || "videos"; +export const BUCKET_NAME = env.RAILWAY_BUCKET_NAME; function getBasePublicUrl(): string { - const baseUrl = process.env.RAILWAY_PUBLIC_URL || process.env.RAILWAY_ENDPOINT; - if (!baseUrl) { - throw new Error("Missing RAILWAY_PUBLIC_URL or RAILWAY_ENDPOINT for bucket URLs"); - } - return baseUrl; + return getRailwayPublicUrl(); } export function buildPublicUrl(key: string): string { - const includeBucket = process.env.RAILWAY_PUBLIC_URL_INCLUDE_BUCKET !== "false"; + const includeBucket = env.RAILWAY_PUBLIC_URL_INCLUDE_BUCKET !== "false"; const url = new URL(getBasePublicUrl()); const basePath = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) @@ -22,19 +19,12 @@ export function buildPublicUrl(key: string): string { } export function getS3Client(): S3Client { - const accessKeyId = process.env.RAILWAY_ACCESS_KEY_ID; - const secretAccessKey = process.env.RAILWAY_SECRET_ACCESS_KEY; - - if (!accessKeyId || !secretAccessKey) { - throw new Error("Missing Railway S3 credentials"); - } - return new S3Client({ - region: process.env.RAILWAY_REGION || "us-east-1", - endpoint: process.env.RAILWAY_ENDPOINT, + region: env.RAILWAY_REGION, + endpoint: env.RAILWAY_ENDPOINT, credentials: { - accessKeyId, - secretAccessKey, + accessKeyId: env.RAILWAY_ACCESS_KEY_ID, + secretAccessKey: env.RAILWAY_SECRET_ACCESS_KEY, }, forcePathStyle: true, }); diff --git a/convex/schema.ts b/convex/schema.ts index 4b2de9e1..4ed933ae 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -88,6 +88,9 @@ export default defineSchema({ thumbnailUrl: v.optional(v.string()), fileSize: v.optional(v.number()), contentType: v.optional(v.string()), + multipartUploadId: v.optional(v.string()), + uploadPartSizeBytes: v.optional(v.number()), + uploadTotalParts: v.optional(v.number()), uploadError: v.optional(v.string()), status: v.union( v.literal("uploading"), diff --git a/convex/videoActions.ts b/convex/videoActions.ts index 57a27daa..c0675a0c 100644 --- a/convex/videoActions.ts +++ b/convex/videoActions.ts @@ -1,10 +1,13 @@ "use node"; import { + AbortMultipartUploadCommand, + CompleteMultipartUploadCommand, + CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, - PutObjectCommand, + UploadPartCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { v } from "convex/values"; @@ -20,8 +23,10 @@ import { } from "./mux"; import { BUCKET_NAME, getS3Client } from "./s3"; -const GIBIBYTE = 1024 ** 3; -const MAX_PRESIGNED_PUT_FILE_SIZE_BYTES = 5 * GIBIBYTE; +const MEBIBYTE = 1024 ** 2; +const DEFAULT_MULTIPART_PART_SIZE_BYTES = 16 * MEBIBYTE; +const MAX_MULTIPART_PARTS = 10_000; +const MAX_PART_URLS_PER_REQUEST = 32; const ALLOWED_UPLOAD_CONTENT_TYPES = new Set([ "video/mp4", "video/quicktime", @@ -152,10 +157,6 @@ function validateUploadRequestOrThrow(args: { fileSize: number; contentType: str throw new Error("Video file size must be greater than zero."); } - if (args.fileSize > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) { - throw new Error("Video file is too large for direct upload."); - } - const normalizedContentType = normalizeContentType(args.contentType); if (!isAllowedUploadContentType(normalizedContentType)) { throw new Error("Unsupported video format. Allowed: mp4, mov, webm, mkv."); @@ -164,16 +165,122 @@ function validateUploadRequestOrThrow(args: { fileSize: number; contentType: str return normalizedContentType; } +function buildUploadKey(videoId: Id<"videos">, filename: string) { + const ext = getExtensionFromKey(filename); + return `videos/${videoId}/${Date.now()}.${ext}`; +} + +function getMultipartPartSizeBytes(fileSize: number) { + const minimumSizeForPartCount = Math.ceil(fileSize / MAX_MULTIPART_PARTS); + const targetPartSize = Math.max( + DEFAULT_MULTIPART_PART_SIZE_BYTES, + minimumSizeForPartCount, + ); + return Math.ceil(targetPartSize / MEBIBYTE) * MEBIBYTE; +} + +function validatePartNumberOrThrow(partNumber: number) { + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > MAX_MULTIPART_PARTS) { + throw new Error("Invalid multipart upload part number."); + } + return partNumber; +} + +function validatePartNumbersOrThrow(partNumbers: number[]) { + if (partNumbers.length === 0) { + throw new Error("At least one multipart upload part number is required."); + } + + if (partNumbers.length > MAX_PART_URLS_PER_REQUEST) { + throw new Error("Too many multipart upload parts requested at once."); + } + + const seenPartNumbers = new Set(); + return partNumbers.map((partNumber) => { + const normalizedPartNumber = validatePartNumberOrThrow(partNumber); + if (seenPartNumbers.has(normalizedPartNumber)) { + throw new Error("Duplicate multipart upload part number."); + } + seenPartNumbers.add(normalizedPartNumber); + return normalizedPartNumber; + }); +} + +function normalizeCompletedPartsOrThrow( + parts: Array<{ partNumber: number; etag: string }>, + expectedPartCount?: number, +) { + if (parts.length === 0) { + throw new Error("At least one completed multipart upload part is required."); + } + + if (expectedPartCount !== undefined && parts.length !== expectedPartCount) { + throw new Error("Multipart upload completed with the wrong number of parts."); + } + + const seenPartNumbers = new Set(); + return [...parts] + .map((part) => { + const partNumber = validatePartNumberOrThrow(part.partNumber); + if (expectedPartCount !== undefined && partNumber > expectedPartCount) { + throw new Error("Multipart upload completed with an unexpected part number."); + } + const etag = part.etag.trim(); + if (!etag) { + throw new Error(`Missing multipart upload ETag for part ${partNumber}.`); + } + if (seenPartNumbers.has(partNumber)) { + throw new Error("Duplicate completed multipart upload part."); + } + seenPartNumbers.add(partNumber); + return { partNumber, etag }; + }) + .sort((a, b) => a.partNumber - b.partNumber); +} + +async function requireMatchingUploadKey( + ctx: ActionCtx, + params: { + videoId: Id<"videos">; + key: string; + multipartUploadId?: string; + }, +) { + const video = await ctx.runQuery(api.videos.getVideoForPlayback, { + videoId: params.videoId, + }); + + if (!video?.s3Key) { + throw new Error("Upload session not found for this video."); + } + + if (video.s3Key !== params.key) { + throw new Error("Upload session no longer matches this video."); + } + + if ( + params.multipartUploadId !== undefined && + video.multipartUploadId !== params.multipartUploadId + ) { + throw new Error("Multipart upload session no longer matches this video."); + } + + return video; +} + function shouldDeleteUploadedObjectOnFailure(error: unknown): boolean { if (!(error instanceof Error)) { return false; } + const message = error.message.toLowerCase(); return ( - error.message.includes("Unsupported video format") || - error.message.includes("Video file is too large") || - error.message.includes("Uploaded video file not found") || - error.message.includes("Storage limit reached") + message.includes("unsupported video format") || + message.includes("file is too large") || + message.includes("uploaded video file not found") || + message.includes("uploaded video file size did not match") || + message.includes("multipart upload completed with") || + message.includes("storage limit") ); } @@ -233,7 +340,7 @@ async function ensurePublicPlaybackId( return resolvedPlaybackId; } -export const getUploadUrl = action({ +export const createMultipartUpload = action({ args: { videoId: v.id("videos"), filename: v.string(), @@ -241,8 +348,10 @@ export const getUploadUrl = action({ contentType: v.string(), }, returns: v.object({ - url: v.string(), - uploadId: v.string(), + key: v.string(), + multipartUploadId: v.string(), + partSizeBytes: v.number(), + totalParts: v.number(), }), handler: async (ctx, args) => { await requireVideoMemberAccess(ctx, args.videoId); @@ -251,24 +360,173 @@ export const getUploadUrl = action({ contentType: args.contentType, }); + const key = buildUploadKey(args.videoId, args.filename); + const partSizeBytes = getMultipartPartSizeBytes(args.fileSize); + const totalParts = Math.max(1, Math.ceil(args.fileSize / partSizeBytes)); const s3 = getS3Client(); - const ext = getExtensionFromKey(args.filename); - const key = `videos/${args.videoId}/${Date.now()}.${ext}`; - const command = new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - ContentType: normalizedContentType, - }); - const url = await getSignedUrl(s3, command, { expiresIn: 3600 }); + const result = await s3.send( + new CreateMultipartUploadCommand({ + Bucket: BUCKET_NAME, + Key: key, + ContentType: normalizedContentType, + }), + ); + + if (!result.UploadId) { + throw new Error("Could not create multipart upload session."); + } await ctx.runMutation(internal.videos.setUploadInfo, { videoId: args.videoId, s3Key: key, fileSize: args.fileSize, contentType: normalizedContentType, + multipartUploadId: result.UploadId, + uploadPartSizeBytes: partSizeBytes, + uploadTotalParts: totalParts, }); - return { url, uploadId: key }; + return { + key, + multipartUploadId: result.UploadId, + partSizeBytes, + totalParts, + }; + }, +}); + +export const getMultipartUploadPartUrls = action({ + args: { + videoId: v.id("videos"), + key: v.string(), + multipartUploadId: v.string(), + partNumbers: v.array(v.number()), + }, + returns: v.object({ + parts: v.array( + v.object({ + partNumber: v.number(), + url: v.string(), + }), + ), + }), + handler: async (ctx, args) => { + await requireVideoMemberAccess(ctx, args.videoId); + const video = await requireMatchingUploadKey(ctx, { + videoId: args.videoId, + key: args.key, + multipartUploadId: args.multipartUploadId, + }); + const normalizedPartNumbers = validatePartNumbersOrThrow(args.partNumbers); + const expectedTotalParts = video.uploadTotalParts; + if ( + expectedTotalParts !== undefined && + normalizedPartNumbers.some((partNumber) => partNumber > expectedTotalParts) + ) { + throw new Error("Requested multipart upload part is outside this upload session."); + } + + const s3 = getS3Client(); + const parts = await Promise.all( + normalizedPartNumbers.map(async (partNumber) => ({ + partNumber, + url: await getSignedUrl( + s3, + new UploadPartCommand({ + Bucket: BUCKET_NAME, + Key: args.key, + UploadId: args.multipartUploadId, + PartNumber: partNumber, + }), + { expiresIn: 3600 }, + ), + })), + ); + + return { parts }; + }, +}); + +export const completeMultipartUpload = action({ + args: { + videoId: v.id("videos"), + key: v.string(), + multipartUploadId: v.string(), + parts: v.array( + v.object({ + partNumber: v.number(), + etag: v.string(), + }), + ), + }, + returns: v.object({ + success: v.boolean(), + }), + handler: async (ctx, args) => { + await requireVideoMemberAccess(ctx, args.videoId); + const video = await requireMatchingUploadKey(ctx, { + videoId: args.videoId, + key: args.key, + multipartUploadId: args.multipartUploadId, + }); + + const completedParts = normalizeCompletedPartsOrThrow( + args.parts, + video.uploadTotalParts, + ); + const s3 = getS3Client(); + await s3.send( + new CompleteMultipartUploadCommand({ + Bucket: BUCKET_NAME, + Key: args.key, + UploadId: args.multipartUploadId, + MultipartUpload: { + Parts: completedParts.map((part) => ({ + PartNumber: part.partNumber, + ETag: part.etag, + })), + }, + }), + ); + + return { success: true }; + }, +}); + +export const abortMultipartUpload = action({ + args: { + videoId: v.id("videos"), + key: v.string(), + multipartUploadId: v.string(), + }, + returns: v.object({ + success: v.boolean(), + }), + handler: async (ctx, args) => { + await requireVideoMemberAccess(ctx, args.videoId); + await requireMatchingUploadKey(ctx, { + videoId: args.videoId, + key: args.key, + multipartUploadId: args.multipartUploadId, + }); + + const s3 = getS3Client(); + try { + await s3.send( + new AbortMultipartUploadCommand({ + Bucket: BUCKET_NAME, + Key: args.key, + UploadId: args.multipartUploadId, + }), + ); + } catch (error) { + if (error instanceof Error && error.name === "NoSuchUpload") { + return { success: true }; + } + throw error; + } + + return { success: true }; }, }); @@ -307,8 +565,12 @@ export const markUploadComplete = action({ throw new Error("Uploaded video file not found or empty."); } const contentLength = contentLengthRaw; - if (contentLength > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) { - throw new Error("Video file is too large for direct upload."); + if ( + typeof video.fileSize === "number" && + Number.isFinite(video.fileSize) && + contentLength !== video.fileSize + ) { + throw new Error("Uploaded video file size did not match the requested upload."); } const normalizedContentType = normalizeContentType( diff --git a/convex/videos.ts b/convex/videos.ts index 81377db1..df99e86c 100644 --- a/convex/videos.ts +++ b/convex/videos.ts @@ -4,7 +4,10 @@ import { identityName, requireProjectAccess, requireVideoAccess } from "./auth"; import { Id } from "./_generated/dataModel"; import { generateUniqueToken } from "./security"; import { resolveActiveShareGrant } from "./shareAccess"; -import { assertTeamCanStoreBytes } from "./billingHelpers"; +import { + assertTeamCanStoreBytes, + assertTeamCanUploadFileBytes, +} from "./billingHelpers"; const workflowStatusValidator = v.union( v.literal("review"), @@ -59,7 +62,9 @@ export const create = mutation({ }, handler: async (ctx, args) => { const { user, project } = await requireProjectAccess(ctx, args.projectId, "member"); - await assertTeamCanStoreBytes(ctx, project.teamId, args.fileSize ?? 0); + const fileSize = args.fileSize ?? 0; + await assertTeamCanUploadFileBytes(ctx, project.teamId, fileSize); + await assertTeamCanStoreBytes(ctx, project.teamId, fileSize); const publicId = await generatePublicId(ctx); const videoId = await ctx.db.insert("videos", { @@ -327,6 +332,9 @@ export const setUploadInfo = internalMutation({ s3Key: v.string(), fileSize: v.number(), contentType: v.string(), + multipartUploadId: v.string(), + uploadPartSizeBytes: v.number(), + uploadTotalParts: v.number(), }, handler: async (ctx, args) => { await ctx.db.patch(args.videoId, { @@ -340,6 +348,9 @@ export const setUploadInfo = internalMutation({ uploadError: undefined, fileSize: args.fileSize, contentType: args.contentType, + multipartUploadId: args.multipartUploadId, + uploadPartSizeBytes: args.uploadPartSizeBytes, + uploadTotalParts: args.uploadTotalParts, status: "uploading", }); }, @@ -369,6 +380,8 @@ export const reconcileUploadedObjectMetadata = internalMutation({ const actualSize = Number.isFinite(args.fileSize) ? Math.max(0, args.fileSize) : 0; const sizeDelta = actualSize - declaredSize; + await assertTeamCanUploadFileBytes(ctx, project.teamId, actualSize); + if (sizeDelta > 0) { await assertTeamCanStoreBytes(ctx, project.teamId, sizeDelta); } @@ -376,6 +389,9 @@ export const reconcileUploadedObjectMetadata = internalMutation({ await ctx.db.patch(args.videoId, { fileSize: actualSize, contentType: args.contentType, + multipartUploadId: undefined, + uploadPartSizeBytes: undefined, + uploadTotalParts: undefined, }); }, }); @@ -423,6 +439,9 @@ export const markAsFailed = internalMutation({ await ctx.db.patch(args.videoId, { muxAssetStatus: "errored", uploadError: args.uploadError, + multipartUploadId: undefined, + uploadPartSizeBytes: undefined, + uploadTotalParts: undefined, status: "failed", }); }, diff --git a/eslint.config.mjs b/eslint.config.mjs index 3af53606..54031109 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,7 +7,7 @@ export default tseslint.config(js.configs.recommended, ...tseslint.configs.recom ".next/**", "build/**", "dist/**", - "app/routeTree.gen.ts", + "src/app/routeTree.gen.ts", "convex/_generated/**", "coverage/**", ], diff --git a/package.json b/package.json index fd84bc65..b5ea534c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "vite build", "build:vercel": "bunx convex deploy --cmd 'bun run build' --cmd-url-env-var-name VITE_CONVEX_URL", "start": "vite preview --port 5296", - "lint": "eslint app src convex --ignore-pattern 'convex/_generated/**'", + "lint": "eslint src convex --ignore-pattern 'convex/_generated/**'", "typecheck": "tsc --noEmit", "typecheck:convex": "bunx convex typecheck", "generate:og": "bun run scripts/generate-og.tsx" @@ -32,6 +32,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@stripe/stripe-js": "^8.7.0", + "@t3-oss/env-core": "^0.13.11", "@tanstack/react-router": "^1.160.2", "@tanstack/react-router-devtools": "^1.160.2", "@tanstack/react-start": "^1.160.2", diff --git a/plans/multipart-plan.md b/plans/multipart-plan.md new file mode 100644 index 00000000..e7727f13 --- /dev/null +++ b/plans/multipart-plan.md @@ -0,0 +1,386 @@ +# Multipart Upload + Plan-Aware Limits Stack + +## Status + +- [x] Stack 1 — Base PR: multipart upload foundation +- [ ] Stack 2 — Follow-up PR: plan-aware limits + better upload errors +- [ ] Stack 3 — Final PR: copy and UI updates + +## Objective + +Roll out larger uploads in the correct order: + +1. **quietly add multipart upload support first** so the app can actually handle files above the current single-request ceiling +2. **layer plan-aware validation and better error messages on top** once the transport is capable +3. **finish with copy/UI updates** so the product accurately communicates the new limits + +This sequencing avoids promising 10GB / 50GB uploads before the upload pipeline can really support them. + +--- + +## Why this order + +The current upload flow uses a single presigned S3 `PUT` from the browser: + +- `convex/videoActions.ts` issues one upload URL +- `app/routes/dashboard/-useVideoUploadManager.ts` uploads the whole file with one `XMLHttpRequest` +- `convex/videoActions.ts` currently enforces a hard cap of `5 * GIBIBYTE` + +That means the app cannot truthfully support 10GB or 50GB uploads until the transport changes to multipart upload. + +So the stack should be: + +1. **Capability first** — multipart upload foundation +2. **Correctness second** — plan-aware size limits + better error messages +3. **Communication last** — pricing/settings/home/upload copy updates + +--- + +## Stack 1 — Base PR: multipart upload foundation + +**Status:** Implemented on the branch. + +### Landed changes + +- Replaced the dashboard uploader's single-request direct upload flow with S3 multipart upload. +- Added multipart Convex actions in `convex/videoActions.ts`: + - `createMultipartUpload` + - `getMultipartUploadPartUrls` + - `completeMultipartUpload` + - `abortMultipartUpload` +- Updated `app/routes/dashboard/-useVideoUploadManager.ts` to: + - split files into parts + - upload parts with bounded parallelism + - batch presigned part URL requests + - aggregate progress and ETA across multipart uploads + - abort in-flight uploads and clean up multipart sessions on failure/cancel +- Removed the old 5 GiB direct-upload bottleneck from the main upload path and from post-upload verification. +- Kept the existing `markUploadComplete` handoff into S3 verification and Mux ingest. + +## Goal + +Replace the current single-request direct upload flow with S3 multipart upload so files larger than 5 GiB are technically possible. + +This PR should be mostly infrastructural and intentionally light on visible product changes. + +## Scope + +### Backend + +Primary file: +- `convex/videoActions.ts` + +Add multipart actions for the upload lifecycle: + +1. `createMultipartUpload` + - verify member access to the video/project + - create the S3 multipart upload + - return enough metadata for the client to upload parts + - persist upload metadata if needed for cleanup/completion + +2. `getMultipartUploadPartUrl` + - presign an `UploadPart` request for a specific part number + - validate the caller can continue the upload + +3. `completeMultipartUpload` + - accept uploaded part metadata from the client + - complete the multipart upload in S3 + - return success metadata to the client + +4. `abortMultipartUpload` + - allow cancellation and cleanup of incomplete multipart uploads + - should be safe to call on cancellation/error paths + +Keep existing post-upload processing logic: +- `markUploadComplete` should remain the handoff into: + - object existence verification + - metadata reconciliation + - transition to `processing` + - Mux ingest kickoff + +### Frontend + +Primary file: +- `app/routes/dashboard/-useVideoUploadManager.ts` + +Replace the single XHR `PUT` flow with multipart upload logic: + +1. split each file into chunks +2. request part URLs from the backend +3. upload parts directly to S3 +4. collect `ETag` values per part +5. complete the multipart upload +6. call `markUploadComplete` + +### Upload behavior requirements + +- preserve current upload tray UX +- preserve cancellation support +- preserve progress percentages and ETA +- support aggregate progress across parts +- use limited parallelism for performance +- avoid obvious waterfalls where possible + +### Suggested implementation details + +- chunk size: something in the ~8–16 MiB range +- concurrency: small fixed parallelism, e.g. 3–5 parts at a time +- retries: optional for this base PR, but at least structure the code so retries can be added without rewriting the uploader +- cancellation: + - abort in-flight browser requests + - call `abortMultipartUpload` + - mark the upload failed/cleaned up consistently + +## Non-goals for this PR + +- no new pricing or marketing copy +- no new user-facing plan messaging +- no visible 10GB / 50GB promise yet +- no attempt to perfect all upload error copy yet + +## Acceptance criteria + +1. uploads no longer depend on a single presigned `PUT` +2. the old 5 GiB direct-upload bottleneck is removed from the main upload path +3. upload progress and cancellation still work in the dashboard upload tray +4. completed uploads still flow into the existing S3 verification + Mux ingestion path +5. small uploads continue to work reliably with no UX regression + +## Likely files touched + +- `convex/videoActions.ts` +- `app/routes/dashboard/-useVideoUploadManager.ts` +- possibly `convex/videos.ts` if extra upload metadata needs to be stored +- possibly `convex/schema.ts` if multipart state must be persisted + +--- + +## Stack 2 — Follow-up PR: plan-aware limits + better upload errors + +## Goal + +Now that multipart exists, enforce the requested per-plan file-size limits and return much better upload errors for the two important failure classes: + +1. **file too large for the plan** +2. **file would push the team over its storage allowance** + +## Requested product behavior + +- Basic plan: **max file size 10GB** +- Pro plan: **max file size 50GB** + +This PR should make those limits real in application logic. + +## Scope + +### Billing/domain constants + +Primary file: +- `convex/billingHelpers.ts` + +Add a new source of truth for per-file upload limits: + +- `TEAM_PLAN_MAX_FILE_SIZE_BYTES` + - `basic: 10 * GIBIBYTE` + - `pro: 50 * GIBIBYTE` + +Keep existing storage limits as separate concerns: +- `TEAM_PLAN_STORAGE_LIMIT_BYTES` + - basic storage remains 100GB + - pro storage remains 1TB + +Consider adding small shared helpers for: +- formatting bytes into readable GB/TB strings +- formatting plan labels consistently + +### Plan-aware file size validation + +Primary file: +- `convex/videos.ts` + +In `create(...)`: +- validate the incoming file size against the team’s plan-specific max file size before upload begins +- continue to validate storage availability with `assertTeamCanStoreBytes(...)` +- fail early so the user doesn’t start a huge upload only to be rejected later + +### Final backend sanity check after upload + +Primary file: +- `convex/videoActions.ts` + +In the post-upload verification path: +- verify the uploaded object actually exists +- verify the final object size is valid +- verify the uploaded object is still within the team’s per-file plan limit +- keep content-type validation in place + +This ensures the client is not the only layer enforcing limits. + +### Better storage-limit errors + +Primary file: +- `convex/billingHelpers.ts` + +Improve `assertTeamCanStoreBytes(...)` to produce richer, actionable messages. + +Instead of a generic message like: +- `Storage limit reached for the basic plan. Upgrade to continue uploading.` + +Prefer messages that include: +- plan name +- current storage usage +- storage limit +- incoming file size +- recommended action + +Example target: +- `This upload would exceed your team's Basic plan storage limit. You're using 96 GB of 100 GB, and this file is 8 GB. Upgrade to Pro or delete old videos to free up space.` + +### Better “file too large” errors + +Return plan-aware messages for file size failures. + +Example targets: + +Basic: +- `This file is too large for the Basic plan. Basic supports files up to 10 GB. Upgrade to Pro for files up to 50 GB.` + +Pro: +- `This file is too large for the Pro plan. Pro supports files up to 50 GB.` + +### Frontend error handling + +Primary file: +- `app/routes/dashboard/-useVideoUploadManager.ts` + +Improve the upload manager so upload tray errors are clearer and less raw. + +Potential improvements: +- normalize backend errors into cleaner display text when needed +- optionally add client-side prechecks before starting multipart upload for instant feedback +- keep inline upload errors attached to the relevant upload item + +Client-side prechecks are useful for convenience, but backend validation remains the source of truth. + +## Acceptance criteria + +1. Basic users cannot upload files over 10GB +2. Pro users cannot upload files over 50GB +3. users get a clearly different error for: + - per-file plan max violation + - total storage limit violation +4. post-upload verification still rejects invalid uploads even if the client misbehaves +5. upload tray surfaces actionable errors instead of generic failures + +## Likely files touched + +- `convex/billingHelpers.ts` +- `convex/videos.ts` +- `convex/videoActions.ts` +- `app/routes/dashboard/-useVideoUploadManager.ts` +- maybe `src/components/upload/UploadProgress.tsx` if tiny copy polish is needed + +--- + +## Stack 3 — Final PR: copy and UI updates + +## Goal + +Update visible plan copy so the product accurately communicates the new per-file upload limits and aligns with the backend behavior. + +This PR should mostly be presentation and messaging. + +## Scope + +### Billing/settings UI + +Primary file: +- `app/routes/dashboard/-settings.tsx` + +Update plan cards/details to show both: +- storage included +- max file size per plan + +Target messaging: +- Basic: `100GB storage` + `Max file size 10GB` +- Pro: `1TB storage` + `Max file size 50GB` + +### Pricing page + +Primary file: +- `app/routes/-pricing.tsx` + +Update pricing cards to include the new per-file max. + +### Homepage pricing section + +Primary file: +- `app/routes/-home.tsx` + +Update the homepage pricing cards / structured copy so it also reflects the new file-size limits. + +### Optional upload entry-point hints + +Possible file: +- `src/components/upload/DropZone.tsx` + +Optional lightweight UX improvement: +- show supported formats more explicitly +- optionally mention that max file size depends on plan, or show the concrete limit if team billing context is available + +This should stay small and not create extra complexity unless the data is already easy to access. + +## Acceptance criteria + +1. settings/billing UI reflects the new per-file limits +2. pricing page reflects the new per-file limits +3. homepage pricing copy reflects the new per-file limits +4. no visible copy conflicts remain between product marketing and actual app behavior + +## Likely files touched + +- `app/routes/dashboard/-settings.tsx` +- `app/routes/-pricing.tsx` +- `app/routes/-home.tsx` +- optionally `src/components/upload/DropZone.tsx` + +--- + +## Cross-stack notes + +## Data and terminology + +The codebase currently uses binary units: +- `GIBIBYTE = 1024 ** 3` +- `TEBIBYTE = 1024 ** 4` + +UI may still say `GB` / `TB` for simplicity, but implementation should remain internally consistent. + +## Important separation of concerns + +There are now two different kinds of size limits: + +1. **Per-file limit** + - Basic: 10GB + - Pro: 50GB + +2. **Total team storage limit** + - Basic: 100GB + - Pro: 1TB + +Error messages and UI copy should make these feel clearly different. + +## Rollout recommendation + +1. land **Stack 1** first with minimal product noise +2. stack **Stack 2** on top so the new capability is enforced correctly +3. stack **Stack 3** last so public and in-app copy only changes once the system is truly ready + +## Success criteria for the full stack + +1. uploads above 5 GiB are technically supported by the transport layer +2. Basic can upload up to 10GB files +3. Pro can upload up to 50GB files +4. users get clear, actionable upload errors +5. billing/pricing/home surfaces all reflect the same limits +6. upload UX remains fast and cancelable with no major regression diff --git a/app/app.css b/src/app/app.css similarity index 100% rename from app/app.css rename to src/app/app.css diff --git a/app/routeTree.gen.ts b/src/app/routeTree.gen.ts similarity index 100% rename from app/routeTree.gen.ts rename to src/app/routeTree.gen.ts diff --git a/app/router.tsx b/src/app/router.tsx similarity index 100% rename from app/router.tsx rename to src/app/router.tsx diff --git a/app/routes/-compare-frameio.tsx b/src/app/routes/-compare-frameio.tsx similarity index 100% rename from app/routes/-compare-frameio.tsx rename to src/app/routes/-compare-frameio.tsx diff --git a/app/routes/-compare-wipster.tsx b/src/app/routes/-compare-wipster.tsx similarity index 100% rename from app/routes/-compare-wipster.tsx rename to src/app/routes/-compare-wipster.tsx diff --git a/app/routes/-for-agencies.tsx b/src/app/routes/-for-agencies.tsx similarity index 100% rename from app/routes/-for-agencies.tsx rename to src/app/routes/-for-agencies.tsx diff --git a/app/routes/-for-video-editors.tsx b/src/app/routes/-for-video-editors.tsx similarity index 100% rename from app/routes/-for-video-editors.tsx rename to src/app/routes/-for-video-editors.tsx diff --git a/app/routes/-home.tsx b/src/app/routes/-home.tsx similarity index 97% rename from app/routes/-home.tsx rename to src/app/routes/-home.tsx index 7c1ffbe1..0a72265e 100644 --- a/app/routes/-home.tsx +++ b/src/app/routes/-home.tsx @@ -259,7 +259,8 @@ export default function Homepage() {
  • Unlimited seats
  • Unlimited projects
  • Unlimited clients
  • -
  • 100GB Storage
  • +
  • 100GB storage
  • +
  • 10GB max file size
  • Get Basic @@ -278,7 +279,8 @@ export default function Homepage() {
  • Unlimited seats
  • Unlimited projects
  • Unlimited clients
  • -
  • 1TB Storage (Whoa)
  • +
  • 1TB storage
  • +
  • 50GB max file size
  • Get Pro @@ -326,7 +328,7 @@ export default function Homepage() { price: "5.00", priceCurrency: "USD", description: - "Unlimited seats, unlimited projects, unlimited clients, 100GB storage", + "Unlimited seats, unlimited projects, unlimited clients, 100GB storage, 10GB max file size", }, { "@type": "Offer", @@ -334,7 +336,7 @@ export default function Homepage() { price: "25.00", priceCurrency: "USD", description: - "Unlimited seats, unlimited projects, unlimited clients, 1TB storage", + "Unlimited seats, unlimited projects, unlimited clients, 1TB storage, 50GB max file size", }, ], creator: { diff --git a/app/routes/-invite.data.ts b/src/app/routes/-invite.data.ts similarity index 100% rename from app/routes/-invite.data.ts rename to src/app/routes/-invite.data.ts diff --git a/app/routes/-invite.tsx b/src/app/routes/-invite.tsx similarity index 100% rename from app/routes/-invite.tsx rename to src/app/routes/-invite.tsx diff --git a/app/routes/-pricing.tsx b/src/app/routes/-pricing.tsx similarity index 94% rename from app/routes/-pricing.tsx rename to src/app/routes/-pricing.tsx index 30ff30a4..eb8859de 100644 --- a/app/routes/-pricing.tsx +++ b/src/app/routes/-pricing.tsx @@ -48,7 +48,11 @@ export default function PricingPage() {
  • {" "} - 100GB Storage + 100GB storage +
  • +
  • + {" "} + 10GB max file size
  • @@ -91,8 +95,12 @@ export default function PricingPage() { Unlimited clients
  • - 1TB - Storage + {" "} + 1TB storage +
  • +
  • + {" "} + 50GB max file size
  • diff --git a/app/routes/-share.data.ts b/src/app/routes/-share.data.ts similarity index 100% rename from app/routes/-share.data.ts rename to src/app/routes/-share.data.ts diff --git a/app/routes/-share.tsx b/src/app/routes/-share.tsx similarity index 100% rename from app/routes/-share.tsx rename to src/app/routes/-share.tsx diff --git a/app/routes/-watch.data.ts b/src/app/routes/-watch.data.ts similarity index 100% rename from app/routes/-watch.data.ts rename to src/app/routes/-watch.data.ts diff --git a/app/routes/-watch.tsx b/src/app/routes/-watch.tsx similarity index 100% rename from app/routes/-watch.tsx rename to src/app/routes/-watch.tsx diff --git a/app/routes/__root.tsx b/src/app/routes/__root.tsx similarity index 94% rename from app/routes/__root.tsx rename to src/app/routes/__root.tsx index d2682276..e963aa8c 100644 --- a/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -11,6 +11,7 @@ import { ConvexClientProvider } from "@/lib/convex"; import { TooltipProvider } from "@/components/ui/tooltip"; import { ThemeProvider } from "@/components/theme/ThemeToggle"; import { NotFound } from "@/components/ui/NotFound"; +import { env } from "@/env/client"; import appCss from "../app.css?url"; export const Route = createRootRoute({ @@ -64,14 +65,8 @@ function RootComponent() { } function AppShell({ children }: { children: ReactNode }) { - const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; - - if (!publishableKey) { - throw new Error("Missing VITE_CLERK_PUBLISHABLE_KEY"); - } - return ( - + {children} ); diff --git a/app/routes/auth/-layout.tsx b/src/app/routes/auth/-layout.tsx similarity index 100% rename from app/routes/auth/-layout.tsx rename to src/app/routes/auth/-layout.tsx diff --git a/app/routes/auth/-sign-in.tsx b/src/app/routes/auth/-sign-in.tsx similarity index 100% rename from app/routes/auth/-sign-in.tsx rename to src/app/routes/auth/-sign-in.tsx diff --git a/app/routes/auth/-sign-up.tsx b/src/app/routes/auth/-sign-up.tsx similarity index 100% rename from app/routes/auth/-sign-up.tsx rename to src/app/routes/auth/-sign-up.tsx diff --git a/app/routes/compare.frameio.tsx b/src/app/routes/compare.frameio.tsx similarity index 100% rename from app/routes/compare.frameio.tsx rename to src/app/routes/compare.frameio.tsx diff --git a/app/routes/compare.wipster.tsx b/src/app/routes/compare.wipster.tsx similarity index 100% rename from app/routes/compare.wipster.tsx rename to src/app/routes/compare.wipster.tsx diff --git a/app/routes/dashboard/$teamSlug.$projectId.$videoId.tsx b/src/app/routes/dashboard/$teamSlug.$projectId.$videoId.tsx similarity index 100% rename from app/routes/dashboard/$teamSlug.$projectId.$videoId.tsx rename to src/app/routes/dashboard/$teamSlug.$projectId.$videoId.tsx diff --git a/app/routes/dashboard/$teamSlug.$projectId.index.tsx b/src/app/routes/dashboard/$teamSlug.$projectId.index.tsx similarity index 100% rename from app/routes/dashboard/$teamSlug.$projectId.index.tsx rename to src/app/routes/dashboard/$teamSlug.$projectId.index.tsx diff --git a/app/routes/dashboard/$teamSlug.$projectId.tsx b/src/app/routes/dashboard/$teamSlug.$projectId.tsx similarity index 100% rename from app/routes/dashboard/$teamSlug.$projectId.tsx rename to src/app/routes/dashboard/$teamSlug.$projectId.tsx diff --git a/app/routes/dashboard/$teamSlug.index.tsx b/src/app/routes/dashboard/$teamSlug.index.tsx similarity index 100% rename from app/routes/dashboard/$teamSlug.index.tsx rename to src/app/routes/dashboard/$teamSlug.index.tsx diff --git a/app/routes/dashboard/$teamSlug.settings.tsx b/src/app/routes/dashboard/$teamSlug.settings.tsx similarity index 100% rename from app/routes/dashboard/$teamSlug.settings.tsx rename to src/app/routes/dashboard/$teamSlug.settings.tsx diff --git a/app/routes/dashboard/$teamSlug.tsx b/src/app/routes/dashboard/$teamSlug.tsx similarity index 100% rename from app/routes/dashboard/$teamSlug.tsx rename to src/app/routes/dashboard/$teamSlug.tsx diff --git a/app/routes/dashboard/-index.data.ts b/src/app/routes/dashboard/-index.data.ts similarity index 100% rename from app/routes/dashboard/-index.data.ts rename to src/app/routes/dashboard/-index.data.ts diff --git a/app/routes/dashboard/-layout.tsx b/src/app/routes/dashboard/-layout.tsx similarity index 93% rename from app/routes/dashboard/-layout.tsx rename to src/app/routes/dashboard/-layout.tsx index 7af49d43..d71a29f0 100644 --- a/app/routes/dashboard/-layout.tsx +++ b/src/app/routes/dashboard/-layout.tsx @@ -1,23 +1,16 @@ import { useAuth } from "@clerk/tanstack-react-start"; -import { useConvex, useQuery } from "convex/react"; -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react"; +import { useQuery } from "convex/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; import { Outlet, - Link, useLocation, useParams, } from "@tanstack/react-router"; import { cn } from "@/lib/utils"; -import { - dashboardHomePath, - teamHomePath, - teamSettingsPath, -} from "@/lib/routes"; -import { useRoutePrewarmIntent } from "@/lib/useRoutePrewarmIntent"; import { Dialog, DialogContent, @@ -26,9 +19,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { UploadProgress } from "@/components/upload/UploadProgress"; -import { prewarmDashboardIndex } from "./-index.data"; -import { prewarmSettings } from "./-settings.data"; -import { prewarmTeam } from "./-team.data"; import { useVideoUploadManager } from "./-useVideoUploadManager"; import { DashboardUploadProvider } from "@/lib/dashboardUploadContext"; @@ -52,7 +42,6 @@ export default function DashboardLayout() { const location = useLocation(); const { pathname, searchStr } = location; const params = useParams({ strict: false }); - const convex = useConvex(); const teamSlug = typeof params.teamSlug === "string" ? params.teamSlug : undefined; const routeProjectId = @@ -65,8 +54,6 @@ export default function DashboardLayout() { api.videos.getPublicIdByVideoId, routeVideoId ? { videoId: routeVideoId } : "skip", ); - const teamHome = teamSlug ? teamHomePath(teamSlug) : null; - const settingsPath = teamSlug ? teamSettingsPath(teamSlug) : null; const uploadTargets = useQuery( api.projects.listUploadTargets, teamSlug ? { teamSlug } : {}, diff --git a/app/routes/dashboard/-project.data.ts b/src/app/routes/dashboard/-project.data.ts similarity index 100% rename from app/routes/dashboard/-project.data.ts rename to src/app/routes/dashboard/-project.data.ts diff --git a/app/routes/dashboard/-project.tsx b/src/app/routes/dashboard/-project.tsx similarity index 99% rename from app/routes/dashboard/-project.tsx rename to src/app/routes/dashboard/-project.tsx index 6244016e..a88171ae 100644 --- a/app/routes/dashboard/-project.tsx +++ b/src/app/routes/dashboard/-project.tsx @@ -1,15 +1,13 @@ import { useAction, useConvex, useMutation, useQuery } from "convex/react"; import { api } from "@convex/_generated/api"; -import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { useState, useCallback, useEffect, useRef, type ReactNode } from "react"; import { DropZone } from "@/components/upload/DropZone"; -import { UploadProgress } from "@/components/upload/UploadProgress"; import { UploadButton } from "@/components/upload/UploadButton"; import { formatDuration, formatRelativeTime } from "@/lib/utils"; import { triggerDownload } from "@/lib/download"; import { - ArrowLeft, Play, MoreVertical, Trash2, @@ -136,8 +134,7 @@ export default function ProjectPage({ api.videoPresence.listProjectOnlineCounts, resolvedProjectId ? { projectId: resolvedProjectId } : "skip", ); - const { requestUpload, uploads } = - useDashboardUploadContext(); + const { requestUpload } = useDashboardUploadContext(); const deleteVideo = useMutation(api.videos.remove); const updateVideoWorkflowStatus = useMutation(api.videos.updateWorkflowStatus); const getDownloadUrl = useAction(api.videoActions.getDownloadUrl); diff --git a/app/routes/dashboard/-routeDataContracts.test.ts b/src/app/routes/dashboard/-routeDataContracts.test.ts similarity index 100% rename from app/routes/dashboard/-routeDataContracts.test.ts rename to src/app/routes/dashboard/-routeDataContracts.test.ts diff --git a/app/routes/dashboard/-settings.data.ts b/src/app/routes/dashboard/-settings.data.ts similarity index 100% rename from app/routes/dashboard/-settings.data.ts rename to src/app/routes/dashboard/-settings.data.ts diff --git a/app/routes/dashboard/-settings.tsx b/src/app/routes/dashboard/-settings.tsx similarity index 96% rename from app/routes/dashboard/-settings.tsx rename to src/app/routes/dashboard/-settings.tsx index 85cb6bbf..6b980a3d 100644 --- a/app/routes/dashboard/-settings.tsx +++ b/src/app/routes/dashboard/-settings.tsx @@ -25,6 +25,7 @@ const BILLING_PLANS: Record< label: string; monthlyPriceUsd: number; storageLimitBytes: number; + maxFileSizeBytes: number; seats: string; } > = { @@ -32,12 +33,14 @@ const BILLING_PLANS: Record< label: "Basic", monthlyPriceUsd: 5, storageLimitBytes: 100 * GIBIBYTE, + maxFileSizeBytes: 10 * GIBIBYTE, seats: "Unlimited", }, pro: { label: "Pro", monthlyPriceUsd: 25, storageLimitBytes: TEBIBYTE, + maxFileSizeBytes: 50 * GIBIBYTE, seats: "Unlimited", }, }; @@ -52,8 +55,13 @@ function normalizeTeamPlan(plan: string): BillingPlan { } function formatBytes(bytes: number): string { - if (bytes >= TEBIBYTE) return `${(bytes / TEBIBYTE).toFixed(1)} TB`; - return `${(bytes / GIBIBYTE).toFixed(1)} GB`; + if (bytes >= TEBIBYTE) { + const value = bytes / TEBIBYTE; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} TB`; + } + + const value = bytes / GIBIBYTE; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} GB`; } function formatUtcDateFromUnixSeconds(unixSeconds: number): string { @@ -324,7 +332,7 @@ export default function TeamSettingsPage() { {/* ── Stats strip ── */} -
    +

    Plan @@ -373,6 +381,14 @@ export default function TeamSettingsPage() { {planConfig.seats}

    +
    +

    + Max file +

    +

    + {formatBytes(planConfig.maxFileSizeBytes)} +

    +
    {/* ── Two-column: Plans + Members ── */} @@ -425,6 +441,7 @@ export default function TeamSettingsPage() { >

    {config.seats} seats

    {formatBytes(config.storageLimitBytes)} storage

    +

    {formatBytes(config.maxFileSizeBytes)} max file size

    {isOwner && !hasActiveSubscription && (