diff --git a/packages/web/package.json b/packages/web/package.json index e438489a..45ebe85a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -20,6 +20,7 @@ "dependencies": { "@ai-sdk/openai": "^2.0.79", "@ai-sdk/react": "^2.0.109", + "@cloudflare/stream-react": "^1.9.3", "@cloudflare/vite-plugin": "^1.17.0", "@electric-sql/client": "^1.2.0", "@flowglad/react": "0.15.0", @@ -47,6 +48,7 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "resend": "^6.5.2", + "stripe": "^20.1.0", "tailwindcss": "^4.1.17", "vite-tsconfig-paths": "^5.1.4", "zod": "^4.1.13" diff --git a/packages/web/src/components/CloudflareStreamPlayer.tsx b/packages/web/src/components/CloudflareStreamPlayer.tsx new file mode 100644 index 00000000..6f7405f5 --- /dev/null +++ b/packages/web/src/components/CloudflareStreamPlayer.tsx @@ -0,0 +1,37 @@ +import { Stream } from "@cloudflare/stream-react" + +type CloudflareStreamPlayerProps = { + uid: string + customerCode?: string + autoPlay?: boolean + muted?: boolean + onReady?: () => void +} + +export function CloudflareStreamPlayer({ + uid, + customerCode, + autoPlay = true, + muted = false, + onReady, +}: CloudflareStreamPlayerProps) { + const handleReady = () => { + onReady?.() + } + + return ( + + ) +} diff --git a/packages/web/src/db/schema.ts b/packages/web/src/db/schema.ts index fdc3f391..6eb99b59 100644 --- a/packages/web/src/db/schema.ts +++ b/packages/web/src/db/schema.ts @@ -25,6 +25,8 @@ export const users = pgTable("users", { .$defaultFn(() => false) .notNull(), image: text("image"), + // Access tiers: 'free' | 'creator' | 'dev' - determines feature access + tier: varchar("tier", { length: 32 }).notNull().default("free"), createdAt: timestamp("createdAt") .$defaultFn(() => new Date()) .notNull(), @@ -261,6 +263,106 @@ export const streams = pgTable("streams", { export const selectStreamsSchema = createSelectSchema(streams) export type Stream = z.infer +// ============================================================================= +// Stripe Billing +// ============================================================================= + +export const stripe_customers = pgTable("stripe_customers", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + user_id: text("user_id") + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + stripe_customer_id: text("stripe_customer_id").notNull().unique(), + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +export const stripe_subscriptions = pgTable("stripe_subscriptions", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + user_id: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + stripe_subscription_id: text("stripe_subscription_id").notNull().unique(), + stripe_customer_id: text("stripe_customer_id").notNull(), + stripe_price_id: text("stripe_price_id").notNull(), + status: varchar("status", { length: 32 }).notNull(), // active, canceled, past_due, etc. + current_period_start: timestamp("current_period_start", { withTimezone: true }), + current_period_end: timestamp("current_period_end", { withTimezone: true }), + cancel_at_period_end: boolean("cancel_at_period_end").default(false), + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +// Track storage usage per billing period +export const storage_usage = pgTable("storage_usage", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + user_id: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // Archive counts + archives_used: integer("archives_used").notNull().default(0), + archives_limit: integer("archives_limit").notNull().default(10), // 10 for paid + // Storage in bytes + storage_bytes_used: integer("storage_bytes_used").notNull().default(0), + storage_bytes_limit: integer("storage_bytes_limit").notNull().default(1073741824), // 1GB default + // Billing period + period_start: timestamp("period_start", { withTimezone: true }).notNull(), + period_end: timestamp("period_end", { withTimezone: true }).notNull(), + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +// ============================================================================= +// Archives (paid video/image/text storage) +// ============================================================================= + +export const archives = pgTable("archives", { + id: uuid("id").primaryKey().defaultRandom(), + user_id: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + title: text("title").notNull(), + description: text("description"), + type: varchar("type", { length: 32 }).notNull(), // 'video', 'image', 'text' + // Content storage + content_url: text("content_url"), // R2/S3 URL for video/image + content_text: text("content_text"), // For text type + thumbnail_url: text("thumbnail_url"), + // Metadata + file_size_bytes: integer("file_size_bytes").default(0), + duration_seconds: integer("duration_seconds"), // For video + mime_type: varchar("mime_type", { length: 128 }), + // Visibility + is_public: boolean("is_public").notNull().default(false), + // Timestamps + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +export const selectArchiveSchema = createSelectSchema(archives) +export type Archive = z.infer + +export const selectStripeCustomerSchema = createSelectSchema(stripe_customers) +export const selectStripeSubscriptionSchema = createSelectSchema(stripe_subscriptions) +export const selectStorageUsageSchema = createSelectSchema(storage_usage) +export type StripeCustomer = z.infer +export type StripeSubscription = z.infer +export type StorageUsage = z.infer + export const selectUsersSchema = createSelectSchema(users) export const selectChatThreadSchema = createSelectSchema(chat_threads) export const selectChatMessageSchema = createSelectSchema(chat_messages) diff --git a/packages/web/src/lib/access.ts b/packages/web/src/lib/access.ts new file mode 100644 index 00000000..da594df6 --- /dev/null +++ b/packages/web/src/lib/access.ts @@ -0,0 +1,142 @@ +import { getAuth } from "./auth" +import { db } from "@/db/connection" +import { users } from "@/db/schema" +import { eq } from "drizzle-orm" + +// User tiers +export type UserTier = "free" | "creator" | "dev" + +// Features and which tiers can access them +const FEATURE_ACCESS: Record = { + // Archive features - creators and devs only + archive_create: ["creator", "dev"], + archive_view_own: ["creator", "dev"], + archive_view_public: ["free", "creator", "dev"], // Anyone can view public archives + + // Stream features - dev only for now + stream_create: ["dev"], + stream_view: ["free", "creator", "dev"], + + // Canvas - everyone + canvas_create: ["free", "creator", "dev"], + + // Sell content - creators only + sell_content: ["creator", "dev"], +} + +export type Feature = keyof typeof FEATURE_ACCESS + +/** + * Get user's tier from database + */ +export async function getUserTier(request: Request): Promise { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return null + } + + const database = db() + + try { + const [user] = await database + .select({ tier: users.tier }) + .from(users) + .where(eq(users.id, session.user.id)) + .limit(1) + + return (user?.tier as UserTier) ?? "free" + } catch (error) { + console.error("[access] Error getting user tier:", error) + return "free" + } +} + +/** + * Check if user has access to a feature + */ +export async function hasFeatureAccess( + request: Request, + feature: Feature +): Promise { + const tier = await getUserTier(request) + + if (!tier) { + // Not authenticated - only allow public features + return FEATURE_ACCESS[feature]?.includes("free") ?? false + } + + const allowedTiers = FEATURE_ACCESS[feature] + if (!allowedTiers) { + // Unknown feature - deny by default + return false + } + + return allowedTiers.includes(tier) +} + +/** + * Get all features user has access to + */ +export async function getUserFeatures(request: Request): Promise { + const tier = await getUserTier(request) + + if (!tier) { + return [] + } + + return Object.entries(FEATURE_ACCESS) + .filter(([_, tiers]) => tiers.includes(tier)) + .map(([feature]) => feature as Feature) +} + +/** + * Check access and return appropriate error response if denied + */ +export async function requireFeatureAccess( + request: Request, + feature: Feature +): Promise { + const hasAccess = await hasFeatureAccess(request, feature) + + if (!hasAccess) { + const tier = await getUserTier(request) + + if (!tier) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }) + } + + return new Response( + JSON.stringify({ + error: "Feature not available", + feature, + currentTier: tier, + requiredTiers: FEATURE_ACCESS[feature], + }), + { + status: 403, + headers: { "content-type": "application/json" }, + } + ) + } + + return null // Access granted +} + +/** + * Upgrade user tier (admin function) + */ +export async function setUserTier( + userId: string, + tier: UserTier +): Promise { + const database = db() + await database + .update(users) + .set({ tier, updatedAt: new Date() }) + .where(eq(users.id, userId)) +} diff --git a/packages/web/src/lib/billing.ts b/packages/web/src/lib/billing.ts index 241a2a28..e2498a05 100644 --- a/packages/web/src/lib/billing.ts +++ b/packages/web/src/lib/billing.ts @@ -1,5 +1,8 @@ import { getFlowgladServer } from "./flowglad" import { getAuth } from "./auth" +import { db } from "@/db/connection" +import { stripe_subscriptions, storage_usage } from "@/db/schema" +import { eq, and, gte, lte } from "drizzle-orm" // Usage limits const GUEST_FREE_REQUESTS = 5 @@ -234,3 +237,274 @@ export async function getBillingSummary(request: Request) { } } } + +// ============================================================================= +// Archive Storage Billing (Stripe-based) +// ============================================================================= + +const ARCHIVE_LIMITS = { + free: { archives: 0, storageBytes: 0 }, + paid: { archives: 10, storageBytes: 1073741824 }, // 1GB +} + +type StorageCheckResult = { + allowed: boolean + archivesRemaining: number + archivesLimit: number + bytesRemaining: number + bytesLimit: number + reason?: "no_subscription" | "archive_limit" | "storage_limit" + isPaid: boolean +} + +/** + * Check if user can create a new archive (requires active Stripe subscription) + */ +export async function checkArchiveAllowed( + request: Request, + fileSizeBytes: number = 0 +): Promise { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return { + allowed: false, + archivesRemaining: 0, + archivesLimit: 0, + bytesRemaining: 0, + bytesLimit: 0, + reason: "no_subscription", + isPaid: false, + } + } + + const database = db() + + try { + // Check for active Stripe subscription + const [subscription] = await database + .select() + .from(stripe_subscriptions) + .where( + and( + eq(stripe_subscriptions.user_id, session.user.id), + eq(stripe_subscriptions.status, "active") + ) + ) + .limit(1) + + if (!subscription) { + return { + allowed: false, + archivesRemaining: 0, + archivesLimit: 0, + bytesRemaining: 0, + bytesLimit: 0, + reason: "no_subscription", + isPaid: false, + } + } + + // Get usage for current billing period + const now = new Date() + const [usage] = await database + .select() + .from(storage_usage) + .where( + and( + eq(storage_usage.user_id, session.user.id), + lte(storage_usage.period_start, now), + gte(storage_usage.period_end, now) + ) + ) + .limit(1) + + const archivesUsed = usage?.archives_used ?? 0 + const archivesLimit = usage?.archives_limit ?? ARCHIVE_LIMITS.paid.archives + const bytesUsed = usage?.storage_bytes_used ?? 0 + const bytesLimit = + usage?.storage_bytes_limit ?? ARCHIVE_LIMITS.paid.storageBytes + + const archivesRemaining = Math.max(0, archivesLimit - archivesUsed) + const bytesRemaining = Math.max(0, bytesLimit - bytesUsed) + + if (archivesRemaining <= 0) { + return { + allowed: false, + archivesRemaining: 0, + archivesLimit, + bytesRemaining, + bytesLimit, + reason: "archive_limit", + isPaid: true, + } + } + + if (fileSizeBytes > 0 && fileSizeBytes > bytesRemaining) { + return { + allowed: false, + archivesRemaining, + archivesLimit, + bytesRemaining, + bytesLimit, + reason: "storage_limit", + isPaid: true, + } + } + + return { + allowed: true, + archivesRemaining, + archivesLimit, + bytesRemaining, + bytesLimit, + isPaid: true, + } + } catch (error) { + console.error("[billing] Error checking archive allowed:", error) + return { + allowed: false, + archivesRemaining: 0, + archivesLimit: 0, + bytesRemaining: 0, + bytesLimit: 0, + reason: "no_subscription", + isPaid: false, + } + } +} + +/** + * Record storage usage after creating an archive + */ +export async function recordStorageUsage( + request: Request, + fileSizeBytes: number +): Promise { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return + } + + const database = db() + + try { + const now = new Date() + const [usage] = await database + .select() + .from(storage_usage) + .where( + and( + eq(storage_usage.user_id, session.user.id), + lte(storage_usage.period_start, now), + gte(storage_usage.period_end, now) + ) + ) + .limit(1) + + if (usage) { + await database + .update(storage_usage) + .set({ + archives_used: usage.archives_used + 1, + storage_bytes_used: usage.storage_bytes_used + fileSizeBytes, + updated_at: now, + }) + .where(eq(storage_usage.id, usage.id)) + + console.log( + `[billing] Recorded storage: +1 archive, +${fileSizeBytes} bytes` + ) + } + } catch (error) { + console.error("[billing] Error recording storage usage:", error) + } +} + +/** + * Get archive billing summary for UI display + */ +export async function getArchiveBillingSummary(request: Request) { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return { + isGuest: true, + isPaid: false, + planName: "Guest", + } + } + + const database = db() + + try { + const [subscription] = await database + .select() + .from(stripe_subscriptions) + .where( + and( + eq(stripe_subscriptions.user_id, session.user.id), + eq(stripe_subscriptions.status, "active") + ) + ) + .limit(1) + + if (subscription) { + const now = new Date() + const [usage] = await database + .select() + .from(storage_usage) + .where( + and( + eq(storage_usage.user_id, session.user.id), + lte(storage_usage.period_start, now), + gte(storage_usage.period_end, now) + ) + ) + .limit(1) + + return { + isGuest: false, + isPaid: true, + planName: "Archive", + storage: { + archivesUsed: usage?.archives_used ?? 0, + archivesLimit: usage?.archives_limit ?? ARCHIVE_LIMITS.paid.archives, + bytesUsed: usage?.storage_bytes_used ?? 0, + bytesLimit: + usage?.storage_bytes_limit ?? ARCHIVE_LIMITS.paid.storageBytes, + }, + currentPeriodEnd: subscription.current_period_end, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + } + } + + return { + isGuest: false, + isPaid: false, + planName: "Free", + } + } catch (error) { + console.error("[billing] Error getting archive summary:", error) + return { + isGuest: false, + isPaid: false, + planName: "Free", + } + } +} + +/** + * Format bytes to human readable string + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B" + const k = 1024 + const sizes = ["B", "KB", "MB", "GB", "TB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` +} diff --git a/packages/web/src/lib/stream/db.ts b/packages/web/src/lib/stream/db.ts index cd2ecb00..17fd5231 100644 --- a/packages/web/src/lib/stream/db.ts +++ b/packages/web/src/lib/stream/db.ts @@ -1,3 +1,5 @@ +import type { StreamPlayback } from "@/lib/stream/playback" + export type StreamPageData = { user: { id: string @@ -12,6 +14,7 @@ export type StreamPageData = { is_live: boolean viewer_count: number hls_url: string | null + playback: StreamPlayback | null thumbnail_url: string | null started_at: string | null } | null diff --git a/packages/web/src/lib/stream/playback.ts b/packages/web/src/lib/stream/playback.ts new file mode 100644 index 00000000..872b1e9e --- /dev/null +++ b/packages/web/src/lib/stream/playback.ts @@ -0,0 +1,77 @@ +export type CloudflareStreamRef = { + uid: string + customerCode?: string +} + +export type StreamPlayback = + | { type: "cloudflare"; uid: string; customerCode?: string } + | { type: "hls"; url: string } + +type PlaybackInput = { + hlsUrl?: string | null + cloudflareUid?: string | null + cloudflareCustomerCode?: string | null +} + +export function resolveStreamPlayback({ + hlsUrl, + cloudflareUid, + cloudflareCustomerCode, +}: PlaybackInput): StreamPlayback | null { + if (cloudflareUid) { + return { + type: "cloudflare", + uid: cloudflareUid, + customerCode: cloudflareCustomerCode ?? undefined, + } + } + + if (!hlsUrl) { + return null + } + + const cloudflare = parseCloudflareStreamUrl(hlsUrl) + if (cloudflare) { + return { + type: "cloudflare", + uid: cloudflare.uid, + customerCode: cloudflare.customerCode, + } + } + + return { type: "hls", url: hlsUrl } +} + +export function parseCloudflareStreamUrl(url: string): CloudflareStreamRef | null { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return null + } + + const host = parsed.hostname.toLowerCase() + const isCloudflareHost = + host.endsWith(".cloudflarestream.com") || + host === "iframe.cloudflarestream.com" || + host.endsWith("videodelivery.net") + + if (!isCloudflareHost) { + return null + } + + const pathParts = parsed.pathname.split("/").filter(Boolean) + if (!pathParts.length) { + return null + } + + const uid = pathParts[0] + if (!uid) { + return null + } + + const customerMatch = host.match(/^customer-([a-z0-9-]+)\.cloudflarestream\.com$/i) + const customerCode = customerMatch?.[1] + + return { uid, customerCode } +} diff --git a/packages/web/src/lib/stripe.ts b/packages/web/src/lib/stripe.ts new file mode 100644 index 00000000..3344e26a --- /dev/null +++ b/packages/web/src/lib/stripe.ts @@ -0,0 +1,51 @@ +import Stripe from "stripe" + +type StripeEnv = { + STRIPE_SECRET_KEY?: string + STRIPE_WEBHOOK_SECRET?: string + STRIPE_ARCHIVE_PRICE_ID?: string // Archive subscription price +} + +const getEnv = (): StripeEnv => { + let STRIPE_SECRET_KEY: string | undefined + let STRIPE_WEBHOOK_SECRET: string | undefined + let STRIPE_ARCHIVE_PRICE_ID: string | undefined + + try { + const { getServerContext } = require("@tanstack/react-start/server") as { + getServerContext: () => { cloudflare?: { env?: StripeEnv } } | null + } + const ctx = getServerContext() + STRIPE_SECRET_KEY = ctx?.cloudflare?.env?.STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET = ctx?.cloudflare?.env?.STRIPE_WEBHOOK_SECRET + STRIPE_ARCHIVE_PRICE_ID = ctx?.cloudflare?.env?.STRIPE_ARCHIVE_PRICE_ID + } catch { + // Not in server context + } + + STRIPE_SECRET_KEY = STRIPE_SECRET_KEY ?? process.env.STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET = + STRIPE_WEBHOOK_SECRET ?? process.env.STRIPE_WEBHOOK_SECRET + STRIPE_ARCHIVE_PRICE_ID = + STRIPE_ARCHIVE_PRICE_ID ?? process.env.STRIPE_ARCHIVE_PRICE_ID + + return { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_ARCHIVE_PRICE_ID } +} + +export const getStripe = (): Stripe | null => { + const env = getEnv() + + if (!env.STRIPE_SECRET_KEY) { + return null + } + + return new Stripe(env.STRIPE_SECRET_KEY) +} + +export const getStripeConfig = () => { + const env = getEnv() + return { + webhookSecret: env.STRIPE_WEBHOOK_SECRET, + archivePriceId: env.STRIPE_ARCHIVE_PRICE_ID, + } +} diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 9ed3904a..d7225c95 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -35,7 +35,10 @@ import { Route as ApiBrowserSessionsRouteImport } from './routes/api/browser-ses import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs' import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request' import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names' +import { Route as ApiUsersUsernameRouteImport } from './routes/api/users.username' import { Route as ApiUsageEventsCreateRouteImport } from './routes/api/usage-events.create' +import { Route as ApiStripeWebhooksRouteImport } from './routes/api/stripe/webhooks' +import { Route as ApiStripeCheckoutRouteImport } from './routes/api/stripe/checkout' import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$username' import { Route as ApiFlowgladSplatRouteImport } from './routes/api/flowglad/$' import { Route as ApiChatMutationsRouteImport } from './routes/api/chat/mutations' @@ -182,11 +185,26 @@ const DemoApiNamesRoute = DemoApiNamesRouteImport.update({ path: '/demo/api/names', getParentRoute: () => rootRouteImport, } as any) +const ApiUsersUsernameRoute = ApiUsersUsernameRouteImport.update({ + id: '/username', + path: '/username', + getParentRoute: () => ApiUsersRoute, +} as any) const ApiUsageEventsCreateRoute = ApiUsageEventsCreateRouteImport.update({ id: '/create', path: '/create', getParentRoute: () => ApiUsageEventsRoute, } as any) +const ApiStripeWebhooksRoute = ApiStripeWebhooksRouteImport.update({ + id: '/api/stripe/webhooks', + path: '/api/stripe/webhooks', + getParentRoute: () => rootRouteImport, +} as any) +const ApiStripeCheckoutRoute = ApiStripeCheckoutRouteImport.update({ + id: '/api/stripe/checkout', + path: '/api/stripe/checkout', + getParentRoute: () => rootRouteImport, +} as any) const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({ id: '/api/streams/$username', path: '/api/streams/$username', @@ -285,7 +303,7 @@ export interface FileRoutesByFullPath { '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute '/api/usage-events': typeof ApiUsageEventsRouteWithChildren - '/api/users': typeof ApiUsersRoute + '/api/users': typeof ApiUsersRouteWithChildren '/canvas/$canvasId': typeof CanvasCanvasIdRoute '/i/1focus-demo': typeof I1focusDemoRoute '/canvas/': typeof CanvasIndexRoute @@ -298,7 +316,10 @@ export interface FileRoutesByFullPath { '/api/chat/mutations': typeof ApiChatMutationsRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute '/api/streams/$username': typeof ApiStreamsUsernameRoute + '/api/stripe/checkout': typeof ApiStripeCheckoutRoute + '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute '/api/usage-events/create': typeof ApiUsageEventsCreateRoute + '/api/users/username': typeof ApiUsersUsernameRoute '/demo/api/names': typeof DemoApiNamesRoute '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute @@ -328,7 +349,7 @@ export interface FileRoutesByTo { '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute '/api/usage-events': typeof ApiUsageEventsRouteWithChildren - '/api/users': typeof ApiUsersRoute + '/api/users': typeof ApiUsersRouteWithChildren '/canvas/$canvasId': typeof CanvasCanvasIdRoute '/i/1focus-demo': typeof I1focusDemoRoute '/canvas': typeof CanvasIndexRoute @@ -341,7 +362,10 @@ export interface FileRoutesByTo { '/api/chat/mutations': typeof ApiChatMutationsRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute '/api/streams/$username': typeof ApiStreamsUsernameRoute + '/api/stripe/checkout': typeof ApiStripeCheckoutRoute + '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute '/api/usage-events/create': typeof ApiUsageEventsCreateRoute + '/api/users/username': typeof ApiUsersUsernameRoute '/demo/api/names': typeof DemoApiNamesRoute '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute @@ -373,7 +397,7 @@ export interface FileRoutesById { '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute '/api/usage-events': typeof ApiUsageEventsRouteWithChildren - '/api/users': typeof ApiUsersRoute + '/api/users': typeof ApiUsersRouteWithChildren '/canvas/$canvasId': typeof CanvasCanvasIdRoute '/i/1focus-demo': typeof I1focusDemoRoute '/canvas/': typeof CanvasIndexRoute @@ -386,7 +410,10 @@ export interface FileRoutesById { '/api/chat/mutations': typeof ApiChatMutationsRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute '/api/streams/$username': typeof ApiStreamsUsernameRoute + '/api/stripe/checkout': typeof ApiStripeCheckoutRoute + '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute '/api/usage-events/create': typeof ApiUsageEventsCreateRoute + '/api/users/username': typeof ApiUsersUsernameRoute '/demo/api/names': typeof DemoApiNamesRoute '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute @@ -432,7 +459,10 @@ export interface FileRouteTypes { | '/api/chat/mutations' | '/api/flowglad/$' | '/api/streams/$username' + | '/api/stripe/checkout' + | '/api/stripe/webhooks' | '/api/usage-events/create' + | '/api/users/username' | '/demo/api/names' | '/demo/start/api-request' | '/demo/start/server-funcs' @@ -475,7 +505,10 @@ export interface FileRouteTypes { | '/api/chat/mutations' | '/api/flowglad/$' | '/api/streams/$username' + | '/api/stripe/checkout' + | '/api/stripe/webhooks' | '/api/usage-events/create' + | '/api/users/username' | '/demo/api/names' | '/demo/start/api-request' | '/demo/start/server-funcs' @@ -519,7 +552,10 @@ export interface FileRouteTypes { | '/api/chat/mutations' | '/api/flowglad/$' | '/api/streams/$username' + | '/api/stripe/checkout' + | '/api/stripe/webhooks' | '/api/usage-events/create' + | '/api/users/username' | '/demo/api/names' | '/demo/start/api-request' | '/demo/start/server-funcs' @@ -551,7 +587,7 @@ export interface RootRouteChildren { ApiProfileRoute: typeof ApiProfileRoute ApiStreamRoute: typeof ApiStreamRoute ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren - ApiUsersRoute: typeof ApiUsersRoute + ApiUsersRoute: typeof ApiUsersRouteWithChildren I1focusDemoRoute: typeof I1focusDemoRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiChatAiRoute: typeof ApiChatAiRoute @@ -559,6 +595,8 @@ export interface RootRouteChildren { ApiChatMutationsRoute: typeof ApiChatMutationsRoute ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRoute + ApiStripeCheckoutRoute: typeof ApiStripeCheckoutRoute + ApiStripeWebhooksRoute: typeof ApiStripeWebhooksRoute DemoApiNamesRoute: typeof DemoApiNamesRoute DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute @@ -752,6 +790,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DemoApiNamesRouteImport parentRoute: typeof rootRouteImport } + '/api/users/username': { + id: '/api/users/username' + path: '/username' + fullPath: '/api/users/username' + preLoaderRoute: typeof ApiUsersUsernameRouteImport + parentRoute: typeof ApiUsersRoute + } '/api/usage-events/create': { id: '/api/usage-events/create' path: '/create' @@ -759,6 +804,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiUsageEventsCreateRouteImport parentRoute: typeof ApiUsageEventsRoute } + '/api/stripe/webhooks': { + id: '/api/stripe/webhooks' + path: '/api/stripe/webhooks' + fullPath: '/api/stripe/webhooks' + preLoaderRoute: typeof ApiStripeWebhooksRouteImport + parentRoute: typeof rootRouteImport + } + '/api/stripe/checkout': { + id: '/api/stripe/checkout' + path: '/api/stripe/checkout' + fullPath: '/api/stripe/checkout' + preLoaderRoute: typeof ApiStripeCheckoutRouteImport + parentRoute: typeof rootRouteImport + } '/api/streams/$username': { id: '/api/streams/$username' path: '/api/streams/$username' @@ -943,6 +1002,18 @@ const ApiUsageEventsRouteWithChildren = ApiUsageEventsRoute._addFileChildren( ApiUsageEventsRouteChildren, ) +interface ApiUsersRouteChildren { + ApiUsersUsernameRoute: typeof ApiUsersUsernameRoute +} + +const ApiUsersRouteChildren: ApiUsersRouteChildren = { + ApiUsersUsernameRoute: ApiUsersUsernameRoute, +} + +const ApiUsersRouteWithChildren = ApiUsersRoute._addFileChildren( + ApiUsersRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, UsernameRoute: UsernameRoute, @@ -963,7 +1034,7 @@ const rootRouteChildren: RootRouteChildren = { ApiProfileRoute: ApiProfileRoute, ApiStreamRoute: ApiStreamRoute, ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren, - ApiUsersRoute: ApiUsersRoute, + ApiUsersRoute: ApiUsersRouteWithChildren, I1focusDemoRoute: I1focusDemoRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiChatAiRoute: ApiChatAiRoute, @@ -971,6 +1042,8 @@ const rootRouteChildren: RootRouteChildren = { ApiChatMutationsRoute: ApiChatMutationsRoute, ApiFlowgladSplatRoute: ApiFlowgladSplatRoute, ApiStreamsUsernameRoute: ApiStreamsUsernameRoute, + ApiStripeCheckoutRoute: ApiStripeCheckoutRoute, + ApiStripeWebhooksRoute: ApiStripeWebhooksRoute, DemoApiNamesRoute: DemoApiNamesRoute, DemoStartApiRequestRoute: DemoStartApiRequestRoute, DemoStartServerFuncsRoute: DemoStartServerFuncsRoute, diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 90543ee5..0d53a2ae 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react" import { createFileRoute } from "@tanstack/react-router" import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db" import { VideoPlayer } from "@/components/VideoPlayer" +import { CloudflareStreamPlayer } from "@/components/CloudflareStreamPlayer" +import { resolveStreamPlayback } from "@/lib/stream/playback" export const Route = createFileRoute("/$username")({ ssr: false, @@ -10,6 +12,7 @@ export const Route = createFileRoute("/$username")({ // Cloudflare Stream HLS URL const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/bb7858eafc85de6c92963f3817477b5d/manifest/video.m3u8" +const NIKIV_PLAYBACK = resolveStreamPlayback({ hlsUrl: HLS_URL }) // Hardcoded user for nikiv const NIKIV_DATA: StreamPageData = { @@ -26,6 +29,7 @@ const NIKIV_DATA: StreamPageData = { is_live: true, viewer_count: 0, hls_url: HLS_URL, + playback: NIKIV_PLAYBACK, thumbnail_url: null, started_at: null, }, @@ -39,35 +43,72 @@ function StreamPage() { const [streamReady, setStreamReady] = useState(false) useEffect(() => { + let isActive = true + const setReadySafe = (ready: boolean) => { + if (isActive) { + setStreamReady(ready) + } + } + const setDataSafe = (next: StreamPageData | null) => { + if (isActive) { + setData(next) + } + } + const setLoadingSafe = (next: boolean) => { + if (isActive) { + setLoading(next) + } + } + const setErrorSafe = (next: string | null) => { + if (isActive) { + setError(next) + } + } + + setReadySafe(false) + // Special handling for nikiv - hardcoded stream if (username === "nikiv") { - setData(NIKIV_DATA) - setLoading(false) - // Check if stream is actually live - fetch(HLS_URL) - .then((res) => setStreamReady(res.ok)) - .catch(() => setStreamReady(false)) - return + setDataSafe(NIKIV_DATA) + setLoadingSafe(false) + + if (NIKIV_PLAYBACK?.type === "hls") { + fetch(NIKIV_PLAYBACK.url) + .then((res) => setReadySafe(res.ok)) + .catch(() => setReadySafe(false)) + } + + return () => { + isActive = false + } } const loadData = async () => { - setLoading(true) - setError(null) + setLoadingSafe(true) + setErrorSafe(null) try { const result = await getStreamByUsername(username) - setData(result) - if (result?.stream?.hls_url) { - const res = await fetch(result.stream.hls_url) - setStreamReady(res.ok) + setDataSafe(result) + + const playback = result?.stream?.playback + if (playback?.type === "hls") { + const res = await fetch(playback.url) + setReadySafe(res.ok) + } else { + setReadySafe(false) } } catch (err) { - setError("Failed to load stream") + setErrorSafe("Failed to load stream") console.error(err) } finally { - setLoading(false) + setLoadingSafe(false) } } loadData() + + return () => { + isActive = false + } }, [username]) if (loading) { @@ -103,12 +144,36 @@ function StreamPage() { } const { user, stream } = data + const playback = stream?.playback + const showPlayer = + playback?.type === "cloudflare" || (playback?.type === "hls" && streamReady) return (
- {stream?.is_live && stream.hls_url && streamReady ? ( - - ) : stream?.is_live && stream.hls_url ? ( + {stream?.is_live && playback && showPlayer ? ( + playback.type === "cloudflare" ? ( +
+ setStreamReady(true)} + /> + {!streamReady && ( +
+
+
🔴
+

+ Connecting to stream... +

+
+
+ )} +
+ ) : ( + + ) + ) : stream?.is_live && playback ? (
🔴
diff --git a/packages/web/src/routes/api/profile.ts b/packages/web/src/routes/api/profile.ts index ca9ed041..a3e44797 100644 --- a/packages/web/src/routes/api/profile.ts +++ b/packages/web/src/routes/api/profile.ts @@ -4,6 +4,7 @@ import { getDb } from "@/db/connection" import { users, streams } from "@/db/schema" import { getAuth } from "@/lib/auth" import { randomUUID } from "crypto" +import { resolveStreamPlayback } from "@/lib/stream/playback" const resolveDatabaseUrl = (request: Request) => { try { @@ -48,6 +49,10 @@ const getProfile = async ({ request }: { request: Request }) => { where: eq(streams.user_id, user.id), }) + const playback = stream + ? resolveStreamPlayback({ hlsUrl: stream.hls_url }) + : null + return new Response( JSON.stringify({ id: user.id, @@ -61,6 +66,7 @@ const getProfile = async ({ request }: { request: Request }) => { title: stream.title, is_live: stream.is_live, hls_url: stream.hls_url, + playback, stream_key: stream.stream_key, } : null, diff --git a/packages/web/src/routes/api/stream.ts b/packages/web/src/routes/api/stream.ts index a6a82691..a29bc0e9 100644 --- a/packages/web/src/routes/api/stream.ts +++ b/packages/web/src/routes/api/stream.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm" import { getDb } from "@/db/connection" import { streams } from "@/db/schema" import { getAuth } from "@/lib/auth" +import { resolveStreamPlayback } from "@/lib/stream/playback" const resolveDatabaseUrl = (request: Request) => { try { @@ -42,7 +43,9 @@ const getStream = async ({ request }: { request: Request }) => { }) } - return new Response(JSON.stringify(stream), { + const playback = resolveStreamPlayback({ hlsUrl: stream.hls_url }) + + return new Response(JSON.stringify({ ...stream, playback }), { status: 200, headers: { "content-type": "application/json" }, }) diff --git a/packages/web/src/routes/api/streams.$username.ts b/packages/web/src/routes/api/streams.$username.ts index e9adb79f..934aad2e 100644 --- a/packages/web/src/routes/api/streams.$username.ts +++ b/packages/web/src/routes/api/streams.$username.ts @@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router" import { eq } from "drizzle-orm" import { getDb } from "@/db/connection" import { users, streams } from "@/db/schema" +import { resolveStreamPlayback } from "@/lib/stream/playback" const resolveDatabaseUrl = (request: Request) => { try { @@ -58,6 +59,10 @@ const serve = async ({ where: eq(streams.user_id, user.id), }) + const playback = stream + ? resolveStreamPlayback({ hlsUrl: stream.hls_url }) + : null + const data = { user: { id: user.id, @@ -73,6 +78,7 @@ const serve = async ({ is_live: stream.is_live, viewer_count: stream.viewer_count, hls_url: stream.hls_url, + playback, thumbnail_url: stream.thumbnail_url, started_at: stream.started_at?.toISOString() ?? null, } diff --git a/packages/web/src/routes/api/stripe/checkout.ts b/packages/web/src/routes/api/stripe/checkout.ts new file mode 100644 index 00000000..efbb5e18 --- /dev/null +++ b/packages/web/src/routes/api/stripe/checkout.ts @@ -0,0 +1,111 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { getStripe, getStripeConfig } from "@/lib/stripe" +import { db } from "@/db/connection" +import { stripe_customers } from "@/db/schema" +import { eq } from "drizzle-orm" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +export const Route = createFileRoute("/api/stripe/checkout")({ + server: { + handlers: { + POST: async ({ request }) => { + const session = await getAuth().api.getSession({ + headers: request.headers, + }) + if (!session?.user?.id) { + return json({ error: "Unauthorized" }, 401) + } + + const stripe = getStripe() + const { archivePriceId } = getStripeConfig() + + if (!stripe) { + console.error("[stripe] Stripe not configured - missing STRIPE_SECRET_KEY") + return json({ error: "Stripe not configured" }, 500) + } + + if (!archivePriceId) { + console.error("[stripe] Price ID not configured - missing STRIPE_ARCHIVE_PRICE_ID") + return json({ error: "Price ID not configured" }, 500) + } + + const database = db() + + try { + // Get or create Stripe customer + let [customer] = await database + .select() + .from(stripe_customers) + .where(eq(stripe_customers.user_id, session.user.id)) + .limit(1) + + let stripeCustomerId: string + + if (customer) { + stripeCustomerId = customer.stripe_customer_id + } else { + // Create new Stripe customer + const stripeCustomer = await stripe.customers.create({ + email: session.user.email, + name: session.user.name ?? undefined, + metadata: { + user_id: session.user.id, + }, + }) + + await database.insert(stripe_customers).values({ + user_id: session.user.id, + stripe_customer_id: stripeCustomer.id, + }) + + stripeCustomerId = stripeCustomer.id + } + + // Parse request body for success/cancel URLs + const body = (await request.json().catch(() => ({}))) as { + successUrl?: string + cancelUrl?: string + } + + const origin = new URL(request.url).origin + const successUrl = body.successUrl ?? `${origin}/archive?billing=success` + const cancelUrl = body.cancelUrl ?? `${origin}/archive?billing=canceled` + + // Create checkout session + const checkoutSession = await stripe.checkout.sessions.create({ + customer: stripeCustomerId, + mode: "subscription", + line_items: [ + { + price: archivePriceId, + quantity: 1, + }, + ], + success_url: successUrl, + cancel_url: cancelUrl, + subscription_data: { + metadata: { + user_id: session.user.id, + }, + }, + }) + + return json({ url: checkoutSession.url }) + } catch (error) { + const err = error as Error + console.error("[stripe] Checkout error:", err.message) + return json( + { error: `Failed to create checkout session: ${err.message}` }, + 500 + ) + } + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/stripe/webhooks.ts b/packages/web/src/routes/api/stripe/webhooks.ts new file mode 100644 index 00000000..bff5a0b4 --- /dev/null +++ b/packages/web/src/routes/api/stripe/webhooks.ts @@ -0,0 +1,201 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getStripe, getStripeConfig } from "@/lib/stripe" +import { db } from "@/db/connection" +import { stripe_customers, stripe_subscriptions, storage_usage } from "@/db/schema" +import { eq, and } from "drizzle-orm" +import type Stripe from "stripe" + +// Archive subscription limits +const ARCHIVE_LIMITS = { + archives: 10, + storageBytes: 1073741824, // 1GB +} + +export const Route = createFileRoute("/api/stripe/webhooks")({ + server: { + handlers: { + POST: async ({ request }) => { + const stripe = getStripe() + const { webhookSecret } = getStripeConfig() + + if (!stripe || !webhookSecret) { + console.error("[stripe] Stripe not configured") + return new Response("Stripe not configured", { status: 500 }) + } + + const body = await request.text() + const signature = request.headers.get("stripe-signature") + + if (!signature) { + return new Response("Missing stripe-signature header", { status: 400 }) + } + + let event: Stripe.Event + try { + event = stripe.webhooks.constructEvent(body, signature, webhookSecret) + } catch (err) { + console.error("[stripe] Webhook signature verification failed:", err) + return new Response("Invalid signature", { status: 400 }) + } + + const database = db() + + try { + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object as Stripe.Checkout.Session + console.log("[stripe] Checkout completed:", session.id) + + if (session.mode === "subscription" && session.subscription) { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ) + await handleSubscriptionCreated(database, subscription) + } + break + } + + case "customer.subscription.created": + case "customer.subscription.updated": { + const subscription = event.data.object as Stripe.Subscription + console.log(`[stripe] Subscription ${event.type}:`, subscription.id) + await handleSubscriptionCreated(database, subscription) + break + } + + case "customer.subscription.deleted": { + const subscription = event.data.object as Stripe.Subscription + console.log("[stripe] Subscription deleted:", subscription.id) + await handleSubscriptionDeleted(database, subscription) + break + } + + case "invoice.payment_succeeded": { + const invoice = event.data.object as Stripe.Invoice + console.log("[stripe] Invoice paid:", invoice.id) + break + } + + case "invoice.payment_failed": { + const invoice = event.data.object as Stripe.Invoice + console.log("[stripe] Invoice payment failed:", invoice.id) + break + } + + default: + console.log(`[stripe] Unhandled event type: ${event.type}`) + } + + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } catch (error) { + console.error("[stripe] Webhook handler error:", error) + return new Response("Webhook handler error", { status: 500 }) + } + }, + }, + }, +}) + +async function handleSubscriptionCreated( + database: ReturnType, + subscription: Stripe.Subscription +) { + const customerId = subscription.customer as string + const priceId = subscription.items.data[0]?.price.id + + // Find user by Stripe customer ID + const [customer] = await database + .select() + .from(stripe_customers) + .where(eq(stripe_customers.stripe_customer_id, customerId)) + .limit(1) + + if (!customer) { + console.error("[stripe] No customer found for:", customerId) + return + } + + // Upsert subscription + const existing = await database + .select() + .from(stripe_subscriptions) + .where(eq(stripe_subscriptions.stripe_subscription_id, subscription.id)) + .limit(1) + + // Period dates + const item = subscription.items.data[0] + const periodStart = item?.current_period_start + ? new Date(item.current_period_start * 1000) + : new Date() + const periodEnd = item?.current_period_end + ? new Date(item.current_period_end * 1000) + : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + + const subscriptionData = { + stripe_subscription_id: subscription.id, + stripe_customer_id: customerId, + stripe_price_id: priceId, + status: subscription.status, + current_period_start: periodStart, + current_period_end: periodEnd, + cancel_at_period_end: subscription.cancel_at_period_end, + updated_at: new Date(), + } + + if (existing.length > 0) { + await database + .update(stripe_subscriptions) + .set(subscriptionData) + .where(eq(stripe_subscriptions.stripe_subscription_id, subscription.id)) + } else { + await database.insert(stripe_subscriptions).values({ + user_id: customer.user_id, + ...subscriptionData, + }) + } + + // Create or update storage usage record for this billing period + const [existingUsage] = await database + .select() + .from(storage_usage) + .where( + and( + eq(storage_usage.user_id, customer.user_id), + eq(storage_usage.period_start, periodStart) + ) + ) + .limit(1) + + if (!existingUsage) { + await database.insert(storage_usage).values({ + user_id: customer.user_id, + archives_used: 0, + archives_limit: ARCHIVE_LIMITS.archives, + storage_bytes_used: 0, + storage_bytes_limit: ARCHIVE_LIMITS.storageBytes, + period_start: periodStart, + period_end: periodEnd, + }) + console.log(`[stripe] Created storage usage record for user ${customer.user_id}`) + } + + console.log(`[stripe] Subscription synced for user ${customer.user_id}`) +} + +async function handleSubscriptionDeleted( + database: ReturnType, + subscription: Stripe.Subscription +) { + await database + .update(stripe_subscriptions) + .set({ + status: "canceled", + updated_at: new Date(), + }) + .where(eq(stripe_subscriptions.stripe_subscription_id, subscription.id)) + + console.log(`[stripe] Subscription ${subscription.id} marked as canceled`) +} diff --git a/packages/web/src/routes/api/users.username.ts b/packages/web/src/routes/api/users.username.ts new file mode 100644 index 00000000..57b3e58a --- /dev/null +++ b/packages/web/src/routes/api/users.username.ts @@ -0,0 +1,122 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { db } from "@/db/connection" +import { users } from "@/db/schema" +import { eq } from "drizzle-orm" + +// Username validation: lowercase letters, numbers, underscores, 3-20 chars +const isValidUsername = (username: string): boolean => { + return /^[a-z0-9_]{3,20}$/.test(username) +} + +const handlePost = async ({ request }: { request: Request }) => { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }) + } + + const body = await request.json() + const { username } = body as { username?: string } + + if (!username || typeof username !== "string") { + return new Response(JSON.stringify({ error: "Username is required" }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + } + + const normalizedUsername = username.toLowerCase().trim() + + if (!isValidUsername(normalizedUsername)) { + return new Response( + JSON.stringify({ + error: + "Username must be 3-20 characters, lowercase letters, numbers, or underscores only", + }), + { + status: 400, + headers: { "content-type": "application/json" }, + }, + ) + } + + const database = db() + + // Check if username is already taken (by another user) + const existing = await database + .select({ id: users.id }) + .from(users) + .where(eq(users.username, normalizedUsername)) + .limit(1) + + if (existing.length > 0 && existing[0].id !== session.user.id) { + return new Response(JSON.stringify({ error: "Username is already taken" }), { + status: 409, + headers: { "content-type": "application/json" }, + }) + } + + // Update username + await database + .update(users) + .set({ username: normalizedUsername, updatedAt: new Date() }) + .where(eq(users.id, session.user.id)) + + return new Response( + JSON.stringify({ success: true, username: normalizedUsername }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) +} + +const handleGet = async ({ request }: { request: Request }) => { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }) + } + + const database = db() + const user = await database + .select({ username: users.username }) + .from(users) + .where(eq(users.id, session.user.id)) + .limit(1) + + return new Response( + JSON.stringify({ username: user[0]?.username ?? null }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) +} + +export const Route = createFileRoute("/api/users/username")({ + server: { + handlers: { + GET: handleGet, + POST: handlePost, + OPTIONS: () => + new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }), + }, + }, +}) diff --git a/packages/web/src/routes/login.tsx b/packages/web/src/routes/login.tsx index 7dddefd2..727b5bd2 100644 --- a/packages/web/src/routes/login.tsx +++ b/packages/web/src/routes/login.tsx @@ -7,22 +7,27 @@ export const Route = createFileRoute("/login")({ ssr: false, }) -type Step = "email" | "otp" +type Step = "email" | "otp" | "username" function AuthPage() { const [step, setStep] = useState("email") const emailInputRef = useRef(null) const otpInputRef = useRef(null) + const usernameInputRef = useRef(null) useEffect(() => { if (step === "email") { emailInputRef.current?.focus() - } else { + } else if (step === "otp") { otpInputRef.current?.focus() + } else if (step === "username") { + usernameInputRef.current?.focus() } }, [step]) + const [email, setEmail] = useState("") const [otp, setOtp] = useState("") + const [username, setUsername] = useState("") const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState("") @@ -60,7 +65,6 @@ function AuthPage() { setError("") try { - // Use signIn.emailOtp for sign-in type OTPs (not verifyEmail which is for email verification) const { error } = await authClient.signIn.emailOtp({ email, otp, @@ -69,7 +73,17 @@ function AuthPage() { if (error) { setError(error.message || "Invalid code") } else { - window.location.href = "/" + // Check if user has a username + const response = await fetch("/api/users/username") + const data = await response.json() + + if (!data.username) { + // New user or user without username - show username setup + setStep("username") + } else { + // Existing user with username - go to home + window.location.href = "/" + } } } catch (err) { console.error("Verify OTP error:", err) @@ -79,6 +93,35 @@ function AuthPage() { } } + const handleSetUsername = async (e: React.FormEvent) => { + e.preventDefault() + if (!username.trim()) return + + setIsLoading(true) + setError("") + + try { + const response = await fetch("/api/users/username", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username.toLowerCase().trim() }), + }) + + const data = await response.json() + + if (!response.ok) { + setError(data.error || "Failed to set username") + } else { + window.location.href = "/" + } + } catch (err) { + console.error("Set username error:", err) + setError("Failed to set username") + } finally { + setIsLoading(false) + } + } + const handleResend = async () => { setIsLoading(true) setError("") @@ -93,7 +136,7 @@ function AuthPage() { if (error) { setError(error.message || "Failed to resend code") } - } catch (err) { + } catch { setError("Failed to resend code") } finally { setIsLoading(false) @@ -101,26 +144,42 @@ function AuthPage() { } const handleBack = () => { - setStep("email") - setOtp("") + if (step === "otp") { + setStep("email") + setOtp("") + } else if (step === "username") { + // Can't go back from username step - they're already signed in + // Just skip for now + window.location.href = "/" + } setError("") } + const handleSkipUsername = () => { + window.location.href = "/" + } + return (

- {step === "email" ? "Sign in" : "Enter code"} + {step === "email" + ? "Sign in" + : step === "otp" + ? "Enter code" + : "Choose username"}

{step === "email" ? "Enter your email to receive a verification code" - : `We sent a 6-digit code to ${email}`} + : step === "otp" + ? `We sent a 6-digit code to ${email}` + : "Pick a unique username for your profile"}

- {step === "email" ? ( + {step === "email" && (
) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5149f76d..1de21064 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: resend: specifier: ^6.5.2 version: 6.5.2 + stripe: + specifier: ^20.1.0 + version: 20.1.0(@types/node@24.10.1) tailwindcss: specifier: ^4.1.17 version: 4.1.17 @@ -2442,6 +2445,14 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2722,6 +2733,10 @@ packages: drizzle-orm: '>=0.36.0' zod: ^3.25.0 || ^4.0.0 + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + electron-to-chromium@1.5.266: resolution: {integrity: sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==} @@ -2743,9 +2758,21 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} @@ -2935,14 +2962,25 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -2973,6 +3011,10 @@ packages: peerDependencies: csstype: ^3.0.10 + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2992,6 +3034,14 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -3260,6 +3310,10 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -3457,6 +3511,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} @@ -3633,6 +3691,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -3773,6 +3835,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3829,6 +3907,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@20.1.0: + resolution: {integrity: sha512-o1VNRuMkY76ZCq92U3EH3/XHm/WHp7AerpzDs4Zyo8uE5mFL4QUcv/2SudWsSnhBSp4moO2+ZoGCZ7mT8crPmQ==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -6340,6 +6427,16 @@ snapshots: cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} caniuse-lite@1.0.30001759: {} @@ -6547,6 +6644,12 @@ snapshots: drizzle-orm: 0.45.0(@cloudflare/workers-types@4.20251205.0)(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@types/pg@8.15.6)(kysely@0.28.8)(pg@8.16.3)(postgres@3.4.7) zod: 4.1.13 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + electron-to-chromium@1.5.266: {} encoding-sniffer@0.2.1: @@ -6565,8 +6668,16 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + es6-promise@4.2.8: {} esbuild-register@3.6.0(esbuild@0.25.12): @@ -6868,10 +6979,30 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-port@7.1.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -6896,6 +7027,8 @@ snapshots: dependencies: csstype: 3.2.3 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -6909,6 +7042,12 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -7157,6 +7296,8 @@ snapshots: markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -7580,6 +7721,8 @@ snapshots: dependencies: boolbase: 1.0.0 + object-inspect@1.13.4: {} + obuf@1.1.2: optional: true @@ -7749,6 +7892,10 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -7934,6 +8081,34 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} simple-swizzle@0.2.4: @@ -7978,6 +8153,12 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@20.1.0(@types/node@24.10.1): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 24.10.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14