diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index f2b68587ca..dbe39b793c 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -5,7 +5,7 @@ "deploy": "wrangler deploy", "bot-dev": "wrangler dev", "start": "wrangler dev", - "test": "vitest", + "test": "vitest run", "cf-typegen": "wrangler types" }, "devDependencies": { diff --git a/apps/media-server/src/__tests__/index.test.ts b/apps/media-server/src/__tests__/index.test.ts index ad7cc1539b..b2a4cdb6e0 100644 --- a/apps/media-server/src/__tests__/index.test.ts +++ b/apps/media-server/src/__tests__/index.test.ts @@ -15,6 +15,7 @@ describe("GET /", () => { "/audio/status", "/audio/check", "/audio/extract", + "/audio/convert", "/video/status", "/video/probe", "/video/thumbnail", diff --git a/apps/web/__tests__/unit/developer-actions.test.ts b/apps/web/__tests__/unit/developer-actions.test.ts new file mode 100644 index 0000000000..cdacd9d5f1 --- /dev/null +++ b/apps/web/__tests__/unit/developer-actions.test.ts @@ -0,0 +1,763 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/database", () => { + const mockDb = { + select: vi.fn(() => mockDb), + insert: vi.fn(() => mockDb), + update: vi.fn(() => mockDb), + delete: vi.fn(() => mockDb), + from: vi.fn(() => mockDb), + set: vi.fn(() => mockDb), + where: vi.fn(() => mockDb), + limit: vi.fn(() => Promise.resolve([])), + values: vi.fn(() => Promise.resolve()), + transaction: vi.fn((fn) => fn(mockDb)), + leftJoin: vi.fn(() => mockDb), + orderBy: vi.fn(() => mockDb), + offset: vi.fn(() => mockDb), + }; + return { + db: () => mockDb, + __mockDb: mockDb, + }; +}); + +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: vi.fn(), +})); + +vi.mock("@cap/database/helpers", () => ({ + nanoId: vi.fn(() => "test-nano-id"), + nanoIdLong: vi.fn(() => "test-nano-id-long-value"), +})); + +vi.mock("@cap/database/schema", () => ({ + developerApps: { + id: "id", + ownerId: "ownerId", + deletedAt: "deletedAt", + }, + developerApiKeys: { + id: "id", + appId: "appId", + keyType: "keyType", + keyPrefix: "keyPrefix", + keyHash: "keyHash", + encryptedKey: "encryptedKey", + revokedAt: "revokedAt", + }, + developerAppDomains: { + id: "id", + appId: "appId", + domain: "domain", + }, + developerVideos: { + id: "id", + appId: "appId", + deletedAt: "deletedAt", + }, + developerCreditAccounts: { + id: "id", + appId: "appId", + balanceMicroCredits: "balanceMicroCredits", + autoTopUpEnabled: "autoTopUpEnabled", + autoTopUpThresholdMicroCredits: "autoTopUpThresholdMicroCredits", + autoTopUpAmountCents: "autoTopUpAmountCents", + }, + developerCreditTransactions: {}, +})); + +vi.mock("@/lib/developer-key-hash", () => ({ + hashKey: vi.fn(() => Promise.resolve("mocked-hash-value")), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), + isNull: vi.fn((field: unknown) => ({ isNull: field })), + sql: vi.fn(), +})); + +import { __mockDb } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; + +const mockGetCurrentUser = getCurrentUser as ReturnType; +const mockDb = __mockDb as Record>; + +const mockUser = { id: "user-123", email: "test@example.com" }; +const mockApp = { + id: "app-456", + ownerId: "user-123", + name: "Test App", + environment: "development", + deletedAt: null, +}; + +function resetMockDb() { + for (const key of Object.keys(mockDb)) { + if (typeof mockDb[key]?.mockClear === "function") { + mockDb[key].mockClear(); + } + } + mockDb.select.mockReturnValue(mockDb); + mockDb.insert.mockReturnValue(mockDb); + mockDb.update.mockReturnValue(mockDb); + mockDb.delete.mockReturnValue(mockDb); + mockDb.from.mockReturnValue(mockDb); + mockDb.set.mockReturnValue(mockDb); + mockDb.where.mockReturnValue(mockDb); + mockDb.limit.mockReturnValue(Promise.resolve([])); + mockDb.values.mockReturnValue(Promise.resolve()); + mockDb.leftJoin.mockReturnValue(mockDb); + mockDb.orderBy.mockReturnValue(mockDb); + mockDb.offset.mockReturnValue(mockDb); + mockDb.transaction.mockImplementation((fn) => fn(mockDb)); +} + +describe("createDeveloperApp", () => { + let createDeveloperApp: typeof import("@/actions/developers/create-app").createDeveloperApp; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/actions/developers/create-app"); + createDeveloperApp = mod.createDeveloperApp; + }); + + it("throws Unauthorized when no user", async () => { + mockGetCurrentUser.mockResolvedValue(null); + await expect( + createDeveloperApp({ name: "Test", environment: "development" }), + ).rejects.toThrow("Unauthorized"); + }); + + it("throws App name is required when empty name", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + await expect( + createDeveloperApp({ name: "", environment: "development" }), + ).rejects.toThrow("App name is required"); + }); + + it("throws App name is required when whitespace-only name", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + await expect( + createDeveloperApp({ name: " ", environment: "development" }), + ).rejects.toThrow("App name is required"); + }); + + it("creates app with trimmed name", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + await createDeveloperApp({ + name: " My App ", + environment: "production", + }); + + expect(mockDb.insert).toHaveBeenCalled(); + const firstValuesCall = mockDb.values.mock.calls[0][0]; + expect(firstValuesCall).toMatchObject({ + name: "My App", + environment: "production", + ownerId: "user-123", + }); + }); + + it("creates public key with cpk_ prefix", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + const result = await createDeveloperApp({ + name: "Test", + environment: "development", + }); + expect(result.publicKey).toMatch(/^cpk_/); + }); + + it("creates secret key with csk_ prefix", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + const result = await createDeveloperApp({ + name: "Test", + environment: "development", + }); + expect(result.secretKey).toMatch(/^csk_/); + }); + + it("creates credit account alongside app", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + await createDeveloperApp({ + name: "Test", + environment: "development", + }); + + expect(mockDb.insert).toHaveBeenCalledTimes(3); + }); + + it("returns appId, publicKey, secretKey", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + const result = await createDeveloperApp({ + name: "Test", + environment: "development", + }); + + expect(result).toHaveProperty("appId"); + expect(result).toHaveProperty("publicKey"); + expect(result).toHaveProperty("secretKey"); + expect(result.appId).toBe("test-nano-id"); + }); + + it("public key prefix is first 12 chars", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + const result = await createDeveloperApp({ + name: "Test", + environment: "development", + }); + + const expectedPrefix = result.publicKey.slice(0, 12); + const keysValuesCall = mockDb.values.mock.calls[1][0]; + const publicKeyEntry = keysValuesCall.find( + (entry: Record) => entry.keyType === "public", + ); + expect(publicKeyEntry.keyPrefix).toBe(expectedPrefix); + }); +}); + +describe("deleteDeveloperApp", () => { + let deleteDeveloperApp: typeof import("@/actions/developers/delete-app").deleteDeveloperApp; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/actions/developers/delete-app"); + deleteDeveloperApp = mod.deleteDeveloperApp; + }); + + it("throws Unauthorized when no user", async () => { + mockGetCurrentUser.mockResolvedValue(null); + await expect(deleteDeveloperApp("app-456")).rejects.toThrow("Unauthorized"); + }); + + it("throws App not found when app does not exist", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValue([]); + await expect(deleteDeveloperApp("app-456")).rejects.toThrow( + "App not found", + ); + }); + + it("soft deletes by setting deletedAt", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await deleteDeveloperApp("app-456"); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date) }), + ); + }); + + it("returns success true", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await deleteDeveloperApp("app-456"); + expect(result).toEqual({ success: true }); + }); +}); + +describe("updateDeveloperApp", () => { + let updateDeveloperApp: typeof import("@/actions/developers/update-app").updateDeveloperApp; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/actions/developers/update-app"); + updateDeveloperApp = mod.updateDeveloperApp; + }); + + it("throws Unauthorized when no user", async () => { + mockGetCurrentUser.mockResolvedValue(null); + await expect( + updateDeveloperApp({ appId: "app-456", name: "New" }), + ).rejects.toThrow("Unauthorized"); + }); + + it("throws App not found when app does not exist", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValue([]); + await expect( + updateDeveloperApp({ appId: "app-456", name: "New" }), + ).rejects.toThrow("App not found"); + }); + + it("updates name when provided", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await updateDeveloperApp({ appId: "app-456", name: "Updated Name" }); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ name: "Updated Name" }), + ); + }); + + it("updates environment when provided", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await updateDeveloperApp({ + appId: "app-456", + environment: "production", + }); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ environment: "production" }), + ); + }); + + it("trims name before saving", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await updateDeveloperApp({ appId: "app-456", name: " Trimmed " }); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ name: "Trimmed" }), + ); + }); + + it("skips update when no fields provided", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await updateDeveloperApp({ appId: "app-456" }); + + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it("returns success true", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await updateDeveloperApp({ + appId: "app-456", + name: "New", + }); + expect(result).toEqual({ success: true }); + }); +}); + +describe("addDeveloperDomain", () => { + let addDeveloperDomain: typeof import("@/actions/developers/add-domain").addDeveloperDomain; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/actions/developers/add-domain"); + addDeveloperDomain = mod.addDeveloperDomain; + }); + + it("throws Unauthorized when no user", async () => { + mockGetCurrentUser.mockResolvedValue(null); + await expect( + addDeveloperDomain("app-456", "https://example.com"), + ).rejects.toThrow("Unauthorized"); + }); + + it("throws Domain is required when empty", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + await expect(addDeveloperDomain("app-456", "")).rejects.toThrow( + "Domain is required", + ); + }); + + it("throws Domain is required when whitespace only", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + await expect(addDeveloperDomain("app-456", " ")).rejects.toThrow( + "Domain is required", + ); + }); + + it("validates domain format with regex", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + await expect(addDeveloperDomain("app-456", "not-a-url")).rejects.toThrow( + "Domain must be a valid origin (e.g. https://myapp.com)", + ); + }); + + it("accepts https://example.com", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await addDeveloperDomain("app-456", "https://example.com"); + expect(result).toEqual({ success: true }); + }); + + it("accepts http://localhost:3000", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await addDeveloperDomain("app-456", "http://localhost:3000"); + expect(result).toEqual({ success: true }); + }); + + it("rejects invalid domains without protocol", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + await expect(addDeveloperDomain("app-456", "example.com")).rejects.toThrow( + "Domain must be a valid origin (e.g. https://myapp.com)", + ); + }); + + it("rejects bare hostnames", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + await expect( + addDeveloperDomain("app-456", "just-hostname"), + ).rejects.toThrow("Domain must be a valid origin (e.g. https://myapp.com)"); + }); + + it("normalizes to lowercase", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await addDeveloperDomain("app-456", "HTTPS://EXAMPLE.COM"); + + expect(mockDb.values).toHaveBeenCalledWith( + expect.objectContaining({ domain: "https://example.com" }), + ); + }); + + it("returns success true", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await addDeveloperDomain("app-456", "https://example.com"); + expect(result).toEqual({ success: true }); + }); +}); + +describe("removeDeveloperDomain", () => { + let removeDeveloperDomain: typeof import("@/actions/developers/remove-domain").removeDeveloperDomain; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/actions/developers/remove-domain"); + removeDeveloperDomain = mod.removeDeveloperDomain; + }); + + it("throws Unauthorized when no user", async () => { + mockGetCurrentUser.mockResolvedValue(null); + await expect( + removeDeveloperDomain("app-456", "domain-789"), + ).rejects.toThrow("Unauthorized"); + }); + + it("throws App not found when app does not exist", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValue([]); + await expect( + removeDeveloperDomain("app-456", "domain-789"), + ).rejects.toThrow("App not found"); + }); + + it("deletes domain record", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await removeDeveloperDomain("app-456", "domain-789"); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("returns success true", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await removeDeveloperDomain("app-456", "domain-789"); + expect(result).toEqual({ success: true }); + }); +}); + +describe("regenerateDeveloperKeys", () => { + let regenerateDeveloperKeys: typeof import("@/actions/developers/regenerate-keys").regenerateDeveloperKeys; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/actions/developers/regenerate-keys"); + regenerateDeveloperKeys = mod.regenerateDeveloperKeys; + }); + + it("throws Unauthorized when no user", async () => { + mockGetCurrentUser.mockResolvedValue(null); + await expect(regenerateDeveloperKeys("app-456")).rejects.toThrow( + "Unauthorized", + ); + }); + + it("throws App not found when app does not exist", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValue([]); + await expect(regenerateDeveloperKeys("app-456")).rejects.toThrow( + "App not found", + ); + }); + + it("revokes all existing active keys", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await regenerateDeveloperKeys("app-456"); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ revokedAt: expect.any(Date) }), + ); + }); + + it("creates new public and secret keys", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await regenerateDeveloperKeys("app-456"); + + expect(result.publicKey).toMatch(/^cpk_/); + expect(result.secretKey).toMatch(/^csk_/); + }); + + it("returns new publicKey and secretKey", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await regenerateDeveloperKeys("app-456"); + + expect(result).toHaveProperty("publicKey"); + expect(result).toHaveProperty("secretKey"); + expect(typeof result.publicKey).toBe("string"); + expect(typeof result.secretKey).toBe("string"); + }); +}); + +describe("addCreditsToAccount", () => { + let addCreditsToAccount: typeof import("@/lib/developer-credits").addCreditsToAccount; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/lib/developer-credits"); + addCreditsToAccount = mod.addCreditsToAccount; + }); + + it("correctly calculates micro-credits from cents", async () => { + mockDb.transaction.mockImplementation(async (fn) => { + const txDb = { ...mockDb }; + txDb.select = vi.fn(() => txDb) as unknown as ReturnType; + txDb.from = vi.fn(() => txDb) as unknown as ReturnType; + txDb.where = vi.fn(() => txDb) as unknown as ReturnType; + txDb.update = vi.fn(() => txDb) as unknown as ReturnType; + txDb.set = vi.fn(() => txDb) as unknown as ReturnType; + txDb.insert = vi.fn(() => txDb) as unknown as ReturnType; + txDb.values = vi.fn(() => Promise.resolve()) as unknown as ReturnType< + typeof vi.fn + >; + txDb.limit = vi.fn(() => + Promise.resolve([{ balanceMicroCredits: 1000000 }]), + ) as unknown as ReturnType; + return fn(txDb); + }); + + const result = await addCreditsToAccount({ + accountId: "account-001", + amountCents: 1000, + }); + expect(result).toBe(1000000); + }); + + it("creates topup transaction record with referenceType", async () => { + let capturedValues: Record | null = null; + mockDb.transaction.mockImplementation(async (fn) => { + const txDb = { ...mockDb }; + txDb.select = vi.fn(() => txDb) as unknown as ReturnType; + txDb.from = vi.fn(() => txDb) as unknown as ReturnType; + txDb.where = vi.fn(() => txDb) as unknown as ReturnType; + txDb.update = vi.fn(() => txDb) as unknown as ReturnType; + txDb.set = vi.fn(() => txDb) as unknown as ReturnType; + txDb.insert = vi.fn(() => txDb) as unknown as ReturnType; + txDb.values = vi.fn((vals: Record) => { + capturedValues = vals; + return Promise.resolve(); + }) as unknown as ReturnType; + txDb.limit = vi.fn(() => + Promise.resolve([{ balanceMicroCredits: 500000 }]), + ) as unknown as ReturnType; + return fn(txDb); + }); + + await addCreditsToAccount({ + accountId: "account-001", + amountCents: 500, + referenceId: "pi_test123", + referenceType: "stripe_payment_intent", + }); + expect(capturedValues).toMatchObject({ + type: "topup", + referenceId: "pi_test123", + referenceType: "stripe_payment_intent", + }); + }); + + it("returns the new balance", async () => { + mockDb.transaction.mockImplementation(async (fn) => { + const txDb = { ...mockDb }; + txDb.select = vi.fn(() => txDb) as unknown as ReturnType; + txDb.from = vi.fn(() => txDb) as unknown as ReturnType; + txDb.where = vi.fn(() => txDb) as unknown as ReturnType; + txDb.update = vi.fn(() => txDb) as unknown as ReturnType; + txDb.set = vi.fn(() => txDb) as unknown as ReturnType; + txDb.insert = vi.fn(() => txDb) as unknown as ReturnType; + txDb.values = vi.fn(() => Promise.resolve()) as unknown as ReturnType< + typeof vi.fn + >; + txDb.limit = vi.fn(() => + Promise.resolve([{ balanceMicroCredits: 750000 }]), + ) as unknown as ReturnType; + return fn(txDb); + }); + + const result = await addCreditsToAccount({ + accountId: "account-001", + amountCents: 750, + }); + expect(result).toBe(750000); + }); +}); + +describe("updateDeveloperAutoTopUp", () => { + let updateDeveloperAutoTopUp: typeof import("@/actions/developers/update-auto-topup").updateDeveloperAutoTopUp; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/actions/developers/update-auto-topup"); + updateDeveloperAutoTopUp = mod.updateDeveloperAutoTopUp; + }); + + it("throws Unauthorized when no user", async () => { + mockGetCurrentUser.mockResolvedValue(null); + await expect( + updateDeveloperAutoTopUp({ appId: "app-456", enabled: true }), + ).rejects.toThrow("Unauthorized"); + }); + + it("throws App not found when app does not exist", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValue([]); + await expect( + updateDeveloperAutoTopUp({ appId: "app-456", enabled: true }), + ).rejects.toThrow("App not found"); + }); + + it("throws Threshold must be non-negative for negative threshold", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await expect( + updateDeveloperAutoTopUp({ + appId: "app-456", + enabled: true, + thresholdMicroCredits: -100, + }), + ).rejects.toThrow("Threshold must be non-negative"); + }); + + it("throws Top-up amount must be positive for zero amount", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await expect( + updateDeveloperAutoTopUp({ + appId: "app-456", + enabled: true, + amountCents: 0, + }), + ).rejects.toThrow("Top-up amount must be positive"); + }); + + it("throws Top-up amount must be positive for negative amount", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await expect( + updateDeveloperAutoTopUp({ + appId: "app-456", + enabled: true, + amountCents: -50, + }), + ).rejects.toThrow("Top-up amount must be positive"); + }); + + it("updates enabled flag", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await updateDeveloperAutoTopUp({ appId: "app-456", enabled: false }); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ autoTopUpEnabled: false }), + ); + }); + + it("conditionally updates threshold and amount", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await updateDeveloperAutoTopUp({ + appId: "app-456", + enabled: true, + thresholdMicroCredits: 50000, + amountCents: 1000, + }); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + autoTopUpEnabled: true, + autoTopUpThresholdMicroCredits: 50000, + autoTopUpAmountCents: 1000, + }), + ); + }); + + it("returns success true", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await updateDeveloperAutoTopUp({ + appId: "app-456", + enabled: true, + }); + expect(result).toEqual({ success: true }); + }); +}); + +describe("deleteDeveloperVideo", () => { + let deleteDeveloperVideo: typeof import("@/actions/developers/delete-video").deleteDeveloperVideo; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/actions/developers/delete-video"); + deleteDeveloperVideo = mod.deleteDeveloperVideo; + }); + + it("throws Unauthorized when no user", async () => { + mockGetCurrentUser.mockResolvedValue(null); + await expect(deleteDeveloperVideo("app-456", "video-001")).rejects.toThrow( + "Unauthorized", + ); + }); + + it("throws App not found when app does not exist", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValue([]); + await expect(deleteDeveloperVideo("app-456", "video-001")).rejects.toThrow( + "App not found", + ); + }); + + it("soft deletes video", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await deleteDeveloperVideo("app-456", "video-001"); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date) }), + ); + }); + + it("returns success true", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await deleteDeveloperVideo("app-456", "video-001"); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/apps/web/__tests__/unit/developer-api-auth.test.ts b/apps/web/__tests__/unit/developer-api-auth.test.ts new file mode 100644 index 0000000000..51bfbffe91 --- /dev/null +++ b/apps/web/__tests__/unit/developer-api-auth.test.ts @@ -0,0 +1,533 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/database", () => { + const mockDb = { + select: vi.fn(() => mockDb), + insert: vi.fn(() => mockDb), + update: vi.fn(() => mockDb), + delete: vi.fn(() => mockDb), + from: vi.fn(() => mockDb), + set: vi.fn(() => mockDb), + where: vi.fn(() => mockDb), + limit: vi.fn(() => Promise.resolve([])), + values: vi.fn(() => Promise.resolve()), + leftJoin: vi.fn(() => mockDb), + }; + return { db: () => mockDb, __mockDb: mockDb }; +}); + +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: vi.fn(), +})); + +vi.mock("@cap/database/schema", () => ({ + developerApiKeys: { + appId: "appId", + keyHash: "keyHash", + keyType: "keyType", + revokedAt: "revokedAt", + lastUsedAt: "lastUsedAt", + }, + developerApps: { id: "id", deletedAt: "deletedAt" }, + developerAppDomains: { appId: "appId", domain: "domain" }, + authApiKeys: { id: "id", userId: "userId" }, + users: { id: "id" }, +})); + +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.so" }, +})); + +vi.mock("@/lib/developer-key-hash", () => ({ + hashKey: vi.fn(() => Promise.resolve("mocked-hash")), +})); + +vi.mock("next/headers", () => ({ + cookies: vi.fn(() => Promise.resolve({ set: vi.fn() })), +})); + +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), + isNull: vi.fn((a: unknown) => ({ isNull: a })), +})); + +describe("developer API auth - key format validation", () => { + describe("public key format (cpk_ prefix)", () => { + it("accepts a key starting with cpk_", () => { + const key = "cpk_abc123"; + expect(key.startsWith("cpk_")).toBe(true); + }); + + it("rejects a secret key for public auth", () => { + const key = "csk_abc123"; + expect(key.startsWith("cpk_")).toBe(false); + }); + + it("rejects a key with no recognized prefix", () => { + const key = "abc123"; + expect(key.startsWith("cpk_")).toBe(false); + }); + + it("rejects an empty string", () => { + const key = ""; + expect(key.startsWith("cpk_")).toBe(false); + }); + + it("rejects undefined via optional chaining", () => { + const key: string | undefined = undefined; + expect(key?.startsWith("cpk_")).toBeFalsy(); + }); + + it("rejects cpk prefix without trailing underscore", () => { + const key = "cpkabc123"; + expect(key.startsWith("cpk_")).toBe(false); + }); + }); + + describe("secret key format (csk_ prefix)", () => { + it("accepts a key starting with csk_", () => { + const key = "csk_abc123"; + expect(key.startsWith("csk_")).toBe(true); + }); + + it("rejects a public key for secret auth", () => { + const key = "cpk_abc123"; + expect(key.startsWith("csk_")).toBe(false); + }); + + it("rejects a key with no recognized prefix", () => { + const key = "abc123"; + expect(key.startsWith("csk_")).toBe(false); + }); + + it("rejects an empty string", () => { + const key = ""; + expect(key.startsWith("csk_")).toBe(false); + }); + + it("rejects undefined via optional chaining", () => { + const key: string | undefined = undefined; + expect(key?.startsWith("csk_")).toBeFalsy(); + }); + + it("rejects csk prefix without trailing underscore", () => { + const key = "cskabc123"; + expect(key.startsWith("csk_")).toBe(false); + }); + }); +}); + +describe("developer API auth - bearer token extraction", () => { + it("extracts the token from a valid Bearer header", () => { + const header = "Bearer cpk_test123"; + const token = header.split(" ")[1]; + expect(token).toBe("cpk_test123"); + }); + + it("returns empty string when Bearer has no token", () => { + const header = "Bearer "; + const token = header.split(" ")[1]; + expect(token).toBe(""); + }); + + it("returns undefined when authorization header is missing", () => { + const header: string | undefined = undefined; + const token = header?.split(" ")[1]; + expect(token).toBeUndefined(); + }); + + it("extracts only the first token segment after Bearer", () => { + const header = "Bearer cpk_test123 extra_stuff"; + const token = header.split(" ")[1]; + expect(token).toBe("cpk_test123"); + }); + + it("returns the raw scheme when no space is present", () => { + const header = "cpk_test123"; + const token = header.split(" ")[1]; + expect(token).toBeUndefined(); + }); + + it("handles lowercase bearer prefix (non-standard)", () => { + const header = "bearer cpk_test123"; + const token = header.split(" ")[1]; + expect(token).toBe("cpk_test123"); + }); +}); + +describe("developer API auth - key revocation check", () => { + it("treats a key with null revokedAt as active", () => { + const keyRow = { appId: "app-1", revokedAt: null }; + const isRevoked = keyRow.revokedAt !== null; + expect(isRevoked).toBe(false); + }); + + it("treats a key with a revokedAt date as revoked", () => { + const keyRow = { + appId: "app-1", + revokedAt: new Date("2025-01-15T00:00:00Z"), + }; + const isRevoked = keyRow.revokedAt !== null; + expect(isRevoked).toBe(true); + }); + + it("middleware returns 401 when no key row is found", () => { + const keyRows: unknown[] = []; + const keyRow = keyRows[0]; + expect(keyRow).toBeUndefined(); + expect(!keyRow).toBe(true); + }); +}); + +describe("developer API auth - app deletion check", () => { + it("treats an app with null deletedAt as active", () => { + const app = { id: "app-1", environment: "production", deletedAt: null }; + const isDeleted = app.deletedAt !== null; + expect(isDeleted).toBe(false); + }); + + it("treats an app with a deletedAt date as deleted", () => { + const app = { + id: "app-1", + environment: "production", + deletedAt: new Date("2025-06-01T00:00:00Z"), + }; + const isDeleted = app.deletedAt !== null; + expect(isDeleted).toBe(true); + }); + + it("middleware returns 401 when no app is found", () => { + const apps: unknown[] = []; + const app = apps[0]; + expect(app).toBeUndefined(); + expect(!app).toBe(true); + }); +}); + +describe("developer API auth - origin validation for production apps", () => { + function validateOrigin( + environment: string, + origin: string | undefined, + allowedDomains: string[], + ): { allowed: boolean; status?: number; error?: string } { + if (environment === "production") { + if (!origin) { + return { + allowed: false, + status: 403, + error: "Origin header required for production apps", + }; + } + const match = allowedDomains.find((d) => d === origin); + if (!match) { + return { allowed: false, status: 403, error: "Origin not allowed" }; + } + } + return { allowed: true }; + } + + it("allows a production app when origin matches an allowed domain", () => { + const result = validateOrigin("production", "https://myapp.com", [ + "https://myapp.com", + "https://other.com", + ]); + expect(result.allowed).toBe(true); + }); + + it("denies a production app when origin does not match any allowed domain", () => { + const result = validateOrigin("production", "https://evil.com", [ + "https://myapp.com", + ]); + expect(result.allowed).toBe(false); + expect(result.status).toBe(403); + expect(result.error).toBe("Origin not allowed"); + }); + + it("denies a production app when no origin header is present", () => { + const result = validateOrigin("production", undefined, [ + "https://myapp.com", + ]); + expect(result.allowed).toBe(false); + expect(result.status).toBe(403); + expect(result.error).toBe("Origin header required for production apps"); + }); + + it("allows a development app with any origin", () => { + const result = validateOrigin("development", "https://anything.com", []); + expect(result.allowed).toBe(true); + }); + + it("allows a development app with no origin", () => { + const result = validateOrigin("development", undefined, []); + expect(result.allowed).toBe(true); + }); + + it("allows a production app when origin matches one of many domains", () => { + const result = validateOrigin("production", "https://second.com", [ + "https://first.com", + "https://second.com", + "https://third.com", + ]); + expect(result.allowed).toBe(true); + }); + + it("performs exact match - no partial domain matching", () => { + const result = validateOrigin("production", "https://myapp.com.evil.com", [ + "https://myapp.com", + ]); + expect(result.allowed).toBe(false); + expect(result.status).toBe(403); + }); + + it("performs exact match - scheme matters", () => { + const result = validateOrigin("production", "http://myapp.com", [ + "https://myapp.com", + ]); + expect(result.allowed).toBe(false); + expect(result.status).toBe(403); + }); + + it("denies a production app with empty string origin", () => { + const result = validateOrigin("production", "", ["https://myapp.com"]); + expect(result.allowed).toBe(false); + expect(result.status).toBe(403); + expect(result.error).toBe("Origin header required for production apps"); + }); + + it("denies a production app when allowed domains list is empty", () => { + const result = validateOrigin("production", "https://myapp.com", []); + expect(result.allowed).toBe(false); + expect(result.status).toBe(403); + expect(result.error).toBe("Origin not allowed"); + }); +}); + +describe("developer API auth - full public auth flow simulation", () => { + function simulatePublicAuth(params: { + authHeader: string | undefined; + keyRow: { appId: string } | undefined; + app: + | { id: string; environment: string; deletedAt: Date | null } + | undefined; + origin: string | undefined; + allowedDomains: string[]; + }): { status: number; error?: string; appId?: string } { + if (!params.authHeader?.startsWith("cpk_")) { + return { status: 401, error: "Invalid public key" }; + } + + if (!params.keyRow) { + return { status: 401, error: "Invalid or revoked public key" }; + } + + if (!params.app) { + return { status: 401, error: "App not found" }; + } + + if (params.app.environment === "production") { + if (!params.origin) { + return { + status: 403, + error: "Origin header required for production apps", + }; + } + const match = params.allowedDomains.find((d) => d === params.origin); + if (!match) { + return { status: 403, error: "Origin not allowed" }; + } + } + + return { status: 200, appId: params.app.id }; + } + + it("succeeds with valid public key, active app, and matching origin", () => { + const result = simulatePublicAuth({ + authHeader: "cpk_live_key123", + keyRow: { appId: "app-1" }, + app: { id: "app-1", environment: "production", deletedAt: null }, + origin: "https://myapp.com", + allowedDomains: ["https://myapp.com"], + }); + expect(result.status).toBe(200); + expect(result.appId).toBe("app-1"); + }); + + it("fails with 401 when no auth header is provided", () => { + const result = simulatePublicAuth({ + authHeader: undefined, + keyRow: undefined, + app: undefined, + origin: undefined, + allowedDomains: [], + }); + expect(result.status).toBe(401); + expect(result.error).toBe("Invalid public key"); + }); + + it("fails with 401 when key uses wrong prefix", () => { + const result = simulatePublicAuth({ + authHeader: "csk_secret_key", + keyRow: undefined, + app: undefined, + origin: undefined, + allowedDomains: [], + }); + expect(result.status).toBe(401); + expect(result.error).toBe("Invalid public key"); + }); + + it("fails with 401 when key is revoked (no key row returned)", () => { + const result = simulatePublicAuth({ + authHeader: "cpk_revoked_key", + keyRow: undefined, + app: undefined, + origin: undefined, + allowedDomains: [], + }); + expect(result.status).toBe(401); + expect(result.error).toBe("Invalid or revoked public key"); + }); + + it("fails with 401 when app is deleted (no app returned)", () => { + const result = simulatePublicAuth({ + authHeader: "cpk_live_key123", + keyRow: { appId: "app-1" }, + app: undefined, + origin: undefined, + allowedDomains: [], + }); + expect(result.status).toBe(401); + expect(result.error).toBe("App not found"); + }); + + it("fails with 403 when production app has no origin", () => { + const result = simulatePublicAuth({ + authHeader: "cpk_live_key123", + keyRow: { appId: "app-1" }, + app: { id: "app-1", environment: "production", deletedAt: null }, + origin: undefined, + allowedDomains: ["https://myapp.com"], + }); + expect(result.status).toBe(403); + expect(result.error).toBe("Origin header required for production apps"); + }); + + it("fails with 403 when production app origin is not in allowed list", () => { + const result = simulatePublicAuth({ + authHeader: "cpk_live_key123", + keyRow: { appId: "app-1" }, + app: { id: "app-1", environment: "production", deletedAt: null }, + origin: "https://evil.com", + allowedDomains: ["https://myapp.com"], + }); + expect(result.status).toBe(403); + expect(result.error).toBe("Origin not allowed"); + }); + + it("succeeds for development app without origin validation", () => { + const result = simulatePublicAuth({ + authHeader: "cpk_dev_key456", + keyRow: { appId: "app-2" }, + app: { id: "app-2", environment: "development", deletedAt: null }, + origin: undefined, + allowedDomains: [], + }); + expect(result.status).toBe(200); + expect(result.appId).toBe("app-2"); + }); +}); + +describe("developer API auth - full secret auth flow simulation", () => { + function simulateSecretAuth(params: { + authHeader: string | undefined; + keyRow: { appId: string } | undefined; + app: + | { id: string; environment: string; deletedAt: Date | null } + | undefined; + }): { status: number; error?: string; appId?: string } { + if (!params.authHeader?.startsWith("csk_")) { + return { status: 401, error: "Invalid secret key" }; + } + + if (!params.keyRow) { + return { status: 401, error: "Invalid or revoked secret key" }; + } + + if (!params.app) { + return { status: 401, error: "App not found" }; + } + + return { status: 200, appId: params.app.id }; + } + + it("succeeds with valid secret key and active app", () => { + const result = simulateSecretAuth({ + authHeader: "csk_live_secret789", + keyRow: { appId: "app-1" }, + app: { id: "app-1", environment: "production", deletedAt: null }, + }); + expect(result.status).toBe(200); + expect(result.appId).toBe("app-1"); + }); + + it("fails with 401 when no auth header is provided", () => { + const result = simulateSecretAuth({ + authHeader: undefined, + keyRow: undefined, + app: undefined, + }); + expect(result.status).toBe(401); + expect(result.error).toBe("Invalid secret key"); + }); + + it("fails with 401 when key uses public prefix", () => { + const result = simulateSecretAuth({ + authHeader: "cpk_public_key", + keyRow: undefined, + app: undefined, + }); + expect(result.status).toBe(401); + expect(result.error).toBe("Invalid secret key"); + }); + + it("fails with 401 when key is revoked (no key row returned)", () => { + const result = simulateSecretAuth({ + authHeader: "csk_revoked_key", + keyRow: undefined, + app: undefined, + }); + expect(result.status).toBe(401); + expect(result.error).toBe("Invalid or revoked secret key"); + }); + + it("fails with 401 when app is deleted (no app returned)", () => { + const result = simulateSecretAuth({ + authHeader: "csk_live_secret789", + keyRow: { appId: "app-1" }, + app: undefined, + }); + expect(result.status).toBe(401); + expect(result.error).toBe("App not found"); + }); + + it("does not perform origin validation even for production apps", () => { + const result = simulateSecretAuth({ + authHeader: "csk_live_secret789", + keyRow: { appId: "app-1" }, + app: { id: "app-1", environment: "production", deletedAt: null }, + }); + expect(result.status).toBe(200); + expect(result.appId).toBe("app-1"); + }); + + it("succeeds for development app without any extra checks", () => { + const result = simulateSecretAuth({ + authHeader: "csk_dev_secret456", + keyRow: { appId: "app-2" }, + app: { id: "app-2", environment: "development", deletedAt: null }, + }); + expect(result.status).toBe(200); + expect(result.appId).toBe("app-2"); + }); +}); diff --git a/apps/web/__tests__/unit/developer-credit-math.test.ts b/apps/web/__tests__/unit/developer-credit-math.test.ts new file mode 100644 index 0000000000..952f101cca --- /dev/null +++ b/apps/web/__tests__/unit/developer-credit-math.test.ts @@ -0,0 +1,175 @@ +const MICRO_CREDITS_PER_DOLLAR = 100_000; +const MICRO_CREDITS_PER_MINUTE = 5_000; +const MICRO_CREDITS_PER_MINUTE_PER_DAY = 3.33; +const MIN_BALANCE_MICRO_CREDITS = 5_000; + +function purchaseCreditsFormula(amountCents: number): number { + return Math.floor((amountCents / 100) * MICRO_CREDITS_PER_DOLLAR); +} + +function videoRecordingCost(durationMinutes: number): number { + return Math.floor(durationMinutes * MICRO_CREDITS_PER_MINUTE); +} + +function dailyStorageCost(totalMinutes: number): number { + return Math.floor(totalMinutes * MICRO_CREDITS_PER_MINUTE_PER_DAY); +} + +function balanceDollars(balanceMicroCredits: number): string { + return (balanceMicroCredits / 100_000).toFixed(2); +} + +function balanceAfterCharge(balance: number, charge: number): number { + return Math.max(0, balance - charge); +} + +describe("Purchase Credits Conversion", () => { + it("converts $5.00 (500 cents) to 500,000 micro-credits", () => { + expect(purchaseCreditsFormula(500)).toBe(500_000); + }); + + it("converts $10.00 (1000 cents) to 1,000,000 micro-credits", () => { + expect(purchaseCreditsFormula(1000)).toBe(1_000_000); + }); + + it("converts $25.00 (2500 cents) to 2,500,000 micro-credits", () => { + expect(purchaseCreditsFormula(2500)).toBe(2_500_000); + }); + + it("converts $50.00 (5000 cents) to 5,000,000 micro-credits", () => { + expect(purchaseCreditsFormula(5000)).toBe(5_000_000); + }); + + it("converts $0.01 (1 cent) to 1,000 micro-credits", () => { + expect(purchaseCreditsFormula(1)).toBe(1_000); + }); + + it("converts $5.99 (599 cents) to 599,000 micro-credits using Math.floor", () => { + expect(purchaseCreditsFormula(599)).toBe(Math.floor(5.99 * 100_000)); + expect(purchaseCreditsFormula(599)).toBe(599_000); + }); + + it("rejects purchases below $5.00 minimum (amountCents < 500)", () => { + expect(499 < 500).toBe(true); + expect(500 < 500).toBe(false); + expect(0 < 500).toBe(true); + }); +}); + +describe("Video Recording Cost", () => { + it("charges 5,000 micro-credits for 1 minute video", () => { + expect(videoRecordingCost(1)).toBe(5_000); + }); + + it("charges 25,000 micro-credits for 5 minute video", () => { + expect(videoRecordingCost(5)).toBe(25_000); + }); + + it("charges 50,000 micro-credits for 10 minute video", () => { + expect(videoRecordingCost(10)).toBe(50_000); + }); + + it("charges 2,500 micro-credits for 30 second video (0.5 min)", () => { + expect(videoRecordingCost(0.5)).toBe(2_500); + }); + + it("charges 7,500 micro-credits for 90 second video (1.5 min)", () => { + expect(videoRecordingCost(1.5)).toBe(7_500); + }); + + it("charges 0 micro-credits for 0 duration", () => { + expect(videoRecordingCost(0)).toBe(0); + }); + + it("charges 83 micro-credits for 1 second video (1/60 min)", () => { + const oneSecondInMinutes = 1 / 60; + expect(videoRecordingCost(oneSecondInMinutes)).toBe( + Math.floor(0.016666666666666666 * 5_000), + ); + expect(videoRecordingCost(oneSecondInMinutes)).toBe(83); + }); +}); + +describe("Daily Storage Cost", () => { + it("charges 3 micro-credits for 1 minute stored", () => { + expect(dailyStorageCost(1)).toBe(Math.floor(1 * 3.33)); + expect(dailyStorageCost(1)).toBe(3); + }); + + it("charges 33 micro-credits for 10 minutes stored", () => { + expect(dailyStorageCost(10)).toBe(Math.floor(10 * 3.33)); + expect(dailyStorageCost(10)).toBe(33); + }); + + it("charges 333 micro-credits for 100 minutes stored", () => { + expect(dailyStorageCost(100)).toBe(Math.floor(100 * 3.33)); + expect(dailyStorageCost(100)).toBe(333); + }); + + it("charges 3330 micro-credits for 1000 minutes stored", () => { + expect(dailyStorageCost(1000)).toBe(Math.floor(1000 * 3.33)); + expect(dailyStorageCost(1000)).toBe(3330); + }); + + it("charges 0 micro-credits for 0 minutes stored", () => { + expect(dailyStorageCost(0)).toBe(0); + }); +}); + +describe("Balance Conversions", () => { + it("converts 0 micro-credits to $0.00", () => { + expect(balanceDollars(0)).toBe("0.00"); + }); + + it("converts 100,000 micro-credits to $1.00", () => { + expect(balanceDollars(100_000)).toBe("1.00"); + }); + + it("converts 500,000 micro-credits to $5.00", () => { + expect(balanceDollars(500_000)).toBe("5.00"); + }); + + it("converts 1 micro-credit to $0.00 (rounds down)", () => { + expect(balanceDollars(1)).toBe("0.00"); + }); + + it("converts 99,999 micro-credits to $1.00 (rounds up)", () => { + expect(balanceDollars(99_999)).toBe("1.00"); + }); + + it("converts 50,000 micro-credits to $0.50", () => { + expect(balanceDollars(50_000)).toBe("0.50"); + }); +}); + +describe("Balance Protection (GREATEST(0, balance - charge))", () => { + it("prevents negative balance: 1000 - 5000 = 0", () => { + expect(balanceAfterCharge(1000, 5000)).toBe(0); + }); + + it("subtracts normally: 10000 - 5000 = 5000", () => { + expect(balanceAfterCharge(10000, 5000)).toBe(5000); + }); + + it("handles exact depletion: 5000 - 5000 = 0", () => { + expect(balanceAfterCharge(5000, 5000)).toBe(0); + }); +}); + +describe("Minimum Balance Check", () => { + it("4,999 micro-credits is insufficient", () => { + expect(4_999 < MIN_BALANCE_MICRO_CREDITS).toBe(true); + }); + + it("5,000 micro-credits is sufficient", () => { + expect(5_000 < MIN_BALANCE_MICRO_CREDITS).toBe(false); + }); + + it("5,001 micro-credits is sufficient", () => { + expect(5_001 < MIN_BALANCE_MICRO_CREDITS).toBe(false); + }); + + it("0 micro-credits is insufficient", () => { + expect(0 < MIN_BALANCE_MICRO_CREDITS).toBe(true); + }); +}); diff --git a/apps/web/__tests__/unit/developer-credits-checkout.test.ts b/apps/web/__tests__/unit/developer-credits-checkout.test.ts new file mode 100644 index 0000000000..895e346cc6 --- /dev/null +++ b/apps/web/__tests__/unit/developer-credits-checkout.test.ts @@ -0,0 +1,367 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDb = { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + from: vi.fn(), + set: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + values: vi.fn(), +}; + +function resetMockDb() { + for (const key of Object.keys(mockDb)) { + const fn = mockDb[key as keyof typeof mockDb]; + if (typeof fn?.mockClear === "function") { + fn.mockClear(); + } + } + mockDb.select.mockReturnValue(mockDb); + mockDb.insert.mockReturnValue(mockDb); + mockDb.update.mockReturnValue(mockDb); + mockDb.from.mockReturnValue(mockDb); + mockDb.set.mockReturnValue(mockDb); + mockDb.where.mockReturnValue(mockDb); + mockDb.limit.mockReturnValue(Promise.resolve([])); + mockDb.values.mockReturnValue(Promise.resolve()); +} + +vi.mock("@cap/database", () => ({ + db: () => mockDb, +})); + +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: vi.fn(), +})); + +vi.mock("@cap/database/schema", () => ({ + developerApps: { + id: "id", + ownerId: "ownerId", + deletedAt: "deletedAt", + }, + developerCreditAccounts: { + id: "id", + appId: "appId", + stripeCustomerId: "stripeCustomerId", + }, + users: { id: "id" }, +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + WEB_URL: "https://cap.test", + }), +})); + +const mockStripe = { + customers: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + checkout: { + sessions: { + create: vi.fn(), + }, + }, +}; + +vi.mock("@cap/utils", () => ({ + stripe: () => mockStripe, +})); + +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), + isNull: vi.fn((a: unknown) => ({ isNull: a })), +})); + +import { getCurrentUser } from "@cap/database/auth/session"; + +const mockGetCurrentUser = getCurrentUser as ReturnType; + +const mockUser = { + id: "user-123", + email: "test@example.com", + stripeCustomerId: null, +}; + +const mockApp = { + id: "app-456", + ownerId: "user-123", + name: "Test App", + deletedAt: null, +}; + +const mockAccount = { + id: "account-001", + appId: "app-456", + stripeCustomerId: null, +}; + +function makeRequest(body: Record) { + return new Request("https://cap.test/api/developer/credits/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("POST /api/developer/credits/checkout", () => { + let POST: typeof import("@/app/api/developer/credits/checkout/route").POST; + + beforeEach(async () => { + vi.clearAllMocks(); + resetMockDb(); + const mod = await import("@/app/api/developer/credits/checkout/route"); + POST = mod.POST; + }); + + it("returns 401 when user is not authenticated", async () => { + mockGetCurrentUser.mockResolvedValue(null); + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 1000 }), + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 400 when appId is missing", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + const res = await POST(makeRequest({ amountCents: 1000 })); + expect(res.status).toBe(400); + }); + + it("returns 400 when amountCents is below minimum", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + const res = await POST(makeRequest({ appId: "app-456", amountCents: 499 })); + expect(res.status).toBe(400); + }); + + it("returns 400 when amountCents is not an integer", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 500.5 }), + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when amountCents is not a number", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + const res = await POST( + makeRequest({ appId: "app-456", amountCents: "1000" }), + ); + expect(res.status).toBe(400); + }); + + it("returns 404 when app is not found", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([]); + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 1000 }), + ); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("App not found"); + }); + + it("returns 404 when credit account is not found", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]).mockResolvedValueOnce([]); + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 1000 }), + ); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Credit account not found"); + }); + + it("creates a new Stripe customer when none exists", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit + .mockResolvedValueOnce([mockApp]) + .mockResolvedValueOnce([mockAccount]); + + const newCustomer = { id: "cus_new123", metadata: {} }; + mockStripe.customers.list.mockResolvedValue({ data: [] }); + mockStripe.customers.create.mockResolvedValue(newCustomer); + mockStripe.checkout.sessions.create.mockResolvedValue({ + url: "https://checkout.stripe.com/test", + }); + + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 1000 }), + ); + expect(res.status).toBe(200); + expect(mockStripe.customers.create).toHaveBeenCalledWith({ + email: "test@example.com", + metadata: { userId: "user-123" }, + }); + }); + + it("reuses existing Stripe customer found by email", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit + .mockResolvedValueOnce([mockApp]) + .mockResolvedValueOnce([mockAccount]); + + const existingCustomer = { + id: "cus_existing", + metadata: { otherKey: "val" }, + }; + mockStripe.customers.list.mockResolvedValue({ + data: [existingCustomer], + }); + mockStripe.customers.update.mockResolvedValue(existingCustomer); + mockStripe.checkout.sessions.create.mockResolvedValue({ + url: "https://checkout.stripe.com/test", + }); + + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 1000 }), + ); + expect(res.status).toBe(200); + expect(mockStripe.customers.update).toHaveBeenCalledWith( + "cus_existing", + expect.objectContaining({ + metadata: expect.objectContaining({ userId: "user-123" }), + }), + ); + expect(mockStripe.customers.create).not.toHaveBeenCalled(); + }); + + it("skips customer creation when account already has stripeCustomerId", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + const accountWithStripe = { ...mockAccount, stripeCustomerId: "cus_acct" }; + mockDb.limit + .mockResolvedValueOnce([mockApp]) + .mockResolvedValueOnce([accountWithStripe]); + + mockStripe.checkout.sessions.create.mockResolvedValue({ + url: "https://checkout.stripe.com/test", + }); + + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 2500 }), + ); + expect(res.status).toBe(200); + expect(mockStripe.customers.list).not.toHaveBeenCalled(); + expect(mockStripe.customers.create).not.toHaveBeenCalled(); + }); + + it("creates checkout session with correct metadata", async () => { + mockGetCurrentUser.mockResolvedValue({ + ...mockUser, + stripeCustomerId: "cus_exist", + }); + const accountWithStripe = { ...mockAccount, stripeCustomerId: "cus_exist" }; + mockDb.limit + .mockResolvedValueOnce([mockApp]) + .mockResolvedValueOnce([accountWithStripe]); + + mockStripe.checkout.sessions.create.mockResolvedValue({ + url: "https://checkout.stripe.com/test", + }); + + await POST(makeRequest({ appId: "app-456", amountCents: 2500 })); + + expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "payment", + metadata: { + type: "developer_credits", + appId: "app-456", + accountId: "account-001", + amountCents: "2500", + userId: "user-123", + }, + }), + ); + }); + + it("returns checkout URL on success", async () => { + mockGetCurrentUser.mockResolvedValue({ + ...mockUser, + stripeCustomerId: "cus_exist", + }); + const accountWithStripe = { ...mockAccount, stripeCustomerId: "cus_exist" }; + mockDb.limit + .mockResolvedValueOnce([mockApp]) + .mockResolvedValueOnce([accountWithStripe]); + + mockStripe.checkout.sessions.create.mockResolvedValue({ + url: "https://checkout.stripe.com/session_abc", + }); + + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 1000 }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.url).toBe("https://checkout.stripe.com/session_abc"); + }); + + it("returns 500 when checkout session has no URL", async () => { + mockGetCurrentUser.mockResolvedValue({ + ...mockUser, + stripeCustomerId: "cus_exist", + }); + const accountWithStripe = { ...mockAccount, stripeCustomerId: "cus_exist" }; + mockDb.limit + .mockResolvedValueOnce([mockApp]) + .mockResolvedValueOnce([accountWithStripe]); + + mockStripe.checkout.sessions.create.mockResolvedValue({ url: null }); + + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 1000 }), + ); + expect(res.status).toBe(500); + }); + + it("returns 500 when Stripe throws", async () => { + mockGetCurrentUser.mockResolvedValue({ + ...mockUser, + stripeCustomerId: "cus_exist", + }); + const accountWithStripe = { ...mockAccount, stripeCustomerId: "cus_exist" }; + mockDb.limit + .mockResolvedValueOnce([mockApp]) + .mockResolvedValueOnce([accountWithStripe]); + + mockStripe.checkout.sessions.create.mockRejectedValue( + new Error("Stripe error"), + ); + + const res = await POST( + makeRequest({ appId: "app-456", amountCents: 1000 }), + ); + expect(res.status).toBe(500); + }); + + it("sets line_items with correct unit_amount", async () => { + mockGetCurrentUser.mockResolvedValue({ + ...mockUser, + stripeCustomerId: "cus_exist", + }); + const accountWithStripe = { ...mockAccount, stripeCustomerId: "cus_exist" }; + mockDb.limit + .mockResolvedValueOnce([mockApp]) + .mockResolvedValueOnce([accountWithStripe]); + + mockStripe.checkout.sessions.create.mockResolvedValue({ + url: "https://checkout.stripe.com/test", + }); + + await POST(makeRequest({ appId: "app-456", amountCents: 5000 })); + + const call = mockStripe.checkout.sessions.create.mock.calls[0]?.[0]; + expect(call.line_items[0].price_data.unit_amount).toBe(5000); + expect(call.line_items[0].price_data.currency).toBe("usd"); + expect(call.line_items[0].quantity).toBe(1); + }); +}); diff --git a/apps/web/__tests__/unit/developer-credits-webhook.test.ts b/apps/web/__tests__/unit/developer-credits-webhook.test.ts new file mode 100644 index 0000000000..7a0fc14eba --- /dev/null +++ b/apps/web/__tests__/unit/developer-credits-webhook.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDbChain = { + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + set: vi.fn(), + values: vi.fn(), +}; + +function resetDbChain() { + for (const key of Object.keys(mockDbChain)) { + const fn = mockDbChain[key as keyof typeof mockDbChain]; + fn.mockClear(); + } + mockDbChain.select.mockReturnValue(mockDbChain); + mockDbChain.from.mockReturnValue(mockDbChain); + mockDbChain.where.mockReturnValue(mockDbChain); + mockDbChain.limit.mockReturnValue(Promise.resolve([])); + mockDbChain.insert.mockReturnValue(mockDbChain); + mockDbChain.update.mockReturnValue(mockDbChain); + mockDbChain.set.mockReturnValue(mockDbChain); + mockDbChain.values.mockReturnValue(Promise.resolve()); +} + +vi.mock("@cap/database", () => ({ + db: () => mockDbChain, +})); + +vi.mock("@cap/database/helpers", () => ({ + nanoId: vi.fn(() => "test-nano-id"), +})); + +vi.mock("@cap/database/schema", () => ({ + developerCreditTransactions: { + id: "id", + accountId: "accountId", + referenceId: "referenceId", + referenceType: "referenceType", + }, + users: { id: "id", email: "email" }, +})); + +vi.mock("@cap/env", () => ({ + buildEnv: { + NEXT_PUBLIC_POSTHOG_KEY: "", + NEXT_PUBLIC_POSTHOG_HOST: "", + }, + serverEnv: () => ({ + STRIPE_WEBHOOK_SECRET: "whsec_test", + }), +})); + +const mockAddCredits = vi.fn(); +vi.mock("@/lib/developer-credits", () => ({ + addCreditsToAccount: (...args: unknown[]) => mockAddCredits(...args), +})); + +vi.mock("@cap/web-domain", () => ({ + Organisation: { OrganisationId: { make: (v: string) => v } }, + User: { UserId: { make: (v: string) => v } }, +})); + +const mockStripe = { + webhooks: { + constructEvent: vi.fn(), + }, + customers: { + retrieve: vi.fn(), + }, + subscriptions: { + retrieve: vi.fn(), + list: vi.fn(), + }, +}; + +vi.mock("@cap/utils", () => ({ + stripe: () => mockStripe, +})); + +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), +})); + +vi.mock("posthog-node", () => ({ + PostHog: vi.fn().mockImplementation(() => ({ + capture: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + })), +})); + +function makeWebhookRequest(body = "{}") { + return new Request("https://cap.test/api/webhooks/stripe", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Stripe-Signature": "sig_test", + }, + body, + }); +} + +function makeCheckoutSession(overrides: Record = {}) { + return { + id: "cs_test_123", + customer: "cus_test", + subscription: null, + payment_intent: "pi_test_abc", + metadata: { + type: "developer_credits", + appId: "app-456", + accountId: "account-001", + amountCents: "2500", + userId: "user-123", + }, + customer_details: { email: "test@example.com" }, + ...overrides, + }; +} + +describe("Stripe webhook — developer credits", () => { + let POST: typeof import("@/app/api/webhooks/stripe/route").POST; + + beforeEach(async () => { + vi.clearAllMocks(); + resetDbChain(); + mockAddCredits.mockResolvedValue(250000); + const mod = await import("@/app/api/webhooks/stripe/route"); + POST = mod.POST; + }); + + it("returns 400 when signature is missing", async () => { + const req = new Request("https://cap.test/api/webhooks/stripe", { + method: "POST", + body: "{}", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when constructEvent throws", async () => { + mockStripe.webhooks.constructEvent.mockImplementation(() => { + throw new Error("Invalid signature"); + }); + const res = await POST(makeWebhookRequest()); + expect(res.status).toBe(400); + const text = await res.text(); + expect(text).toContain("Invalid signature"); + }); + + it("adds credits on valid developer_credits checkout", async () => { + const session = makeCheckoutSession(); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: session }, + }); + mockDbChain.limit.mockResolvedValueOnce([]); + + const res = await POST(makeWebhookRequest()); + expect(res.status).toBe(200); + expect(mockAddCredits).toHaveBeenCalledWith({ + accountId: "account-001", + amountCents: 2500, + referenceId: "pi_test_abc", + referenceType: "stripe_payment_intent", + metadata: { + amountCents: 2500, + stripeSessionId: "cs_test_123", + }, + }); + }); + + it("converts amountCents from string to number", async () => { + const session = makeCheckoutSession({ + metadata: { + type: "developer_credits", + appId: "app-456", + accountId: "account-001", + amountCents: "5000", + userId: "user-123", + }, + }); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: session }, + }); + mockDbChain.limit.mockResolvedValueOnce([]); + + await POST(makeWebhookRequest()); + expect(mockAddCredits).toHaveBeenCalledWith( + expect.objectContaining({ amountCents: 5000 }), + ); + }); + + it("skips duplicate webhook delivery (idempotency)", async () => { + const session = makeCheckoutSession(); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: session }, + }); + mockDbChain.limit.mockResolvedValueOnce([{ id: "existing-txn-id" }]); + + const res = await POST(makeWebhookRequest()); + expect(res.status).toBe(200); + expect(mockAddCredits).not.toHaveBeenCalled(); + }); + + it("returns 400 when payment_intent is missing", async () => { + const session = makeCheckoutSession({ payment_intent: null }); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: session }, + }); + + const res = await POST(makeWebhookRequest()); + expect(res.status).toBe(400); + expect(mockAddCredits).not.toHaveBeenCalled(); + }); + + it("returns 400 when accountId is missing from metadata", async () => { + const session = makeCheckoutSession({ + metadata: { + type: "developer_credits", + amountCents: "1000", + userId: "user-123", + }, + }); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: session }, + }); + + const res = await POST(makeWebhookRequest()); + expect(res.status).toBe(400); + expect(mockAddCredits).not.toHaveBeenCalled(); + }); + + it("returns 400 when amountCents is missing from metadata", async () => { + const session = makeCheckoutSession({ + metadata: { + type: "developer_credits", + accountId: "account-001", + userId: "user-123", + }, + }); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: session }, + }); + + const res = await POST(makeWebhookRequest()); + expect(res.status).toBe(400); + expect(mockAddCredits).not.toHaveBeenCalled(); + }); + + it("does not fall through to subscription logic for developer_credits", async () => { + const session = makeCheckoutSession(); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: session }, + }); + mockDbChain.limit.mockResolvedValueOnce([]); + + await POST(makeWebhookRequest()); + expect(mockStripe.customers.retrieve).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.retrieve).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/developer-cron-storage.test.ts b/apps/web/__tests__/unit/developer-cron-storage.test.ts new file mode 100644 index 0000000000..bf80901fa2 --- /dev/null +++ b/apps/web/__tests__/unit/developer-cron-storage.test.ts @@ -0,0 +1,318 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDb = vi.fn(); + +vi.mock("@cap/database", () => ({ + db: mockDb, +})); + +vi.mock("@cap/database/helpers", () => ({ + nanoId: vi.fn(() => "test-nano-id"), +})); + +vi.mock("@cap/database/schema", () => ({ + developerApps: { id: "id", deletedAt: "deletedAt" }, + developerCreditAccounts: { + id: "id", + appId: "appId", + balanceMicroCredits: "balanceMicroCredits", + }, + developerCreditTransactions: {}, + developerDailyStorageSnapshots: { + appId: "appId", + snapshotDate: "snapshotDate", + id: "id", + processedAt: "processedAt", + }, + developerVideos: { + appId: "appId", + deletedAt: "deletedAt", + duration: "duration", + }, +})); + +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: any[]) => args), + eq: vi.fn((a: any, b: any) => ({ eq: [a, b] })), + isNull: vi.fn((a: any) => ({ isNull: a })), + sql: vi.fn(), +})); + +let capturedJsonBody: any = null; +let capturedStatus: number | undefined; + +vi.mock("next/server", () => ({ + NextResponse: { + json: vi.fn((body: any, init?: any) => { + capturedJsonBody = body; + capturedStatus = init?.status; + return { body, status: init?.status ?? 200 }; + }), + }, +})); + +const CRON_SECRET = "test-cron-secret"; + +function makeChain( + result: any[], + options?: { onTransaction?: (tx: any) => void }, +) { + const chain: any = { + select: vi.fn(() => chain), + from: vi.fn(() => chain), + where: vi.fn(() => chain), + limit: vi.fn(() => Promise.resolve(result)), + insert: vi.fn(() => chain), + values: vi.fn(() => Promise.resolve()), + update: vi.fn(() => chain), + set: vi.fn(() => chain), + transaction: vi.fn(async (cb: any) => { + const txChain: any = { + select: vi.fn(() => txChain), + from: vi.fn(() => txChain), + where: vi.fn(() => txChain), + limit: vi.fn(() => Promise.resolve([{ balanceMicroCredits: 0 }])), + insert: vi.fn(() => txChain), + values: vi.fn(() => Promise.resolve()), + update: vi.fn(() => txChain), + set: vi.fn(() => txChain), + }; + if (options?.onTransaction) { + options.onTransaction(txChain); + } + await cb(txChain); + }), + }; + chain.then = (resolve: any) => resolve(result); + return chain; +} + +function setupDbSequence( + responses: any[][], + txOptions?: { onTransaction?: (tx: any) => void }, +) { + let callIndex = 0; + mockDb.mockImplementation(() => { + const idx = callIndex++; + const result = idx < responses.length ? responses[idx] : []; + return makeChain(result, txOptions); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + capturedJsonBody = null; + capturedStatus = undefined; + process.env.CRON_SECRET = CRON_SECRET; +}); + +async function importGET() { + const mod = await import("@/app/api/cron/developer-storage/route"); + return mod.GET; +} + +function makeRequest(authHeader?: string): Request { + const headers = new Headers(); + if (authHeader) { + headers.set("authorization", authHeader); + } + return new Request("https://localhost/api/cron/developer-storage", { + method: "GET", + headers, + }); +} + +describe("developer-storage cron job", () => { + describe("authentication", () => { + it("returns 401 when no auth header", async () => { + const GET = await importGET(); + await GET(makeRequest()); + + expect(capturedJsonBody).toEqual({ error: "Unauthorized" }); + expect(capturedStatus).toBe(401); + }); + + it("returns 401 when wrong bearer token", async () => { + const GET = await importGET(); + await GET(makeRequest("Bearer wrong-secret")); + + expect(capturedJsonBody).toEqual({ error: "Unauthorized" }); + expect(capturedStatus).toBe(401); + }); + }); + + describe("processing apps", () => { + it("processes apps with videos and charges correctly", async () => { + const GET = await importGET(); + + setupDbSequence([ + [{ id: "app-1" }], + [], + [{ totalDurationMinutes: 10, videoCount: 5 }], + [{ id: "account-1", appId: "app-1", balanceMicroCredits: 1000 }], + [], + ]); + + await GET(makeRequest(`Bearer ${CRON_SECRET}`)); + + expect(capturedJsonBody).toMatchObject({ + success: true, + appsProcessed: 1, + }); + expect(capturedJsonBody.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("skips already-processed apps", async () => { + const GET = await importGET(); + + setupDbSequence([ + [{ id: "app-1" }], + [{ id: "snapshot-1", processedAt: new Date() }], + ]); + + await GET(makeRequest(`Bearer ${CRON_SECRET}`)); + + expect(capturedJsonBody).toMatchObject({ + success: true, + appsProcessed: 0, + }); + }); + + it("skips apps with 0 total duration", async () => { + const GET = await importGET(); + + setupDbSequence([ + [{ id: "app-1" }], + [], + [{ totalDurationMinutes: 0, videoCount: 0 }], + ]); + + await GET(makeRequest(`Bearer ${CRON_SECRET}`)); + + expect(capturedJsonBody).toMatchObject({ + success: true, + appsProcessed: 0, + }); + }); + + it("skips apps with no credit account", async () => { + const GET = await importGET(); + + setupDbSequence([ + [{ id: "app-1" }], + [], + [{ totalDurationMinutes: 10, videoCount: 5 }], + [], + ]); + + await GET(makeRequest(`Bearer ${CRON_SECRET}`)); + + expect(capturedJsonBody).toMatchObject({ + success: true, + appsProcessed: 0, + }); + }); + }); + + describe("transaction behavior", () => { + it("creates negative transaction for storage_daily", async () => { + const GET = await importGET(); + let insertValues: any = null; + + setupDbSequence( + [ + [{ id: "app-1" }], + [], + [{ totalDurationMinutes: 10, videoCount: 5 }], + [{ id: "account-1", appId: "app-1", balanceMicroCredits: 1000 }], + [], + ], + { + onTransaction: (tx) => { + tx.values.mockImplementation((vals: any) => { + if (vals?.type === "storage_daily") { + insertValues = vals; + } + return Promise.resolve(); + }); + }, + }, + ); + + await GET(makeRequest(`Bearer ${CRON_SECRET}`)); + + expect(insertValues).not.toBeNull(); + expect(insertValues.type).toBe("storage_daily"); + expect(insertValues.amountMicroCredits).toBe(-33); + }); + + it("transaction amount is negative (debit)", async () => { + const GET = await importGET(); + let capturedAmount: number | null = null; + + setupDbSequence( + [ + [{ id: "app-1" }], + [], + [{ totalDurationMinutes: 10, videoCount: 3 }], + [{ id: "account-1", appId: "app-1", balanceMicroCredits: 500 }], + [], + ], + { + onTransaction: (tx) => { + tx.values.mockImplementation((vals: any) => { + if (vals?.type === "storage_daily") { + capturedAmount = vals.amountMicroCredits; + } + return Promise.resolve(); + }); + }, + }, + ); + + await GET(makeRequest(`Bearer ${CRON_SECRET}`)); + + expect(capturedAmount).toBeLessThan(0); + }); + }); + + describe("response format", () => { + it("returns correct response format", async () => { + const GET = await importGET(); + + setupDbSequence([[]]); + + await GET(makeRequest(`Bearer ${CRON_SECRET}`)); + + expect(capturedJsonBody).toHaveProperty("success", true); + expect(capturedJsonBody).toHaveProperty("date"); + expect(capturedJsonBody).toHaveProperty("appsProcessed"); + expect(capturedJsonBody.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(typeof capturedJsonBody.appsProcessed).toBe("number"); + }); + }); + + describe("storage rate calculations", () => { + const MICRO_CREDITS_PER_MINUTE_PER_DAY = 3.33; + + it("1 minute -> 3 credits", () => { + expect(Math.floor(1 * MICRO_CREDITS_PER_MINUTE_PER_DAY)).toBe(3); + }); + + it("10 minutes -> 33 credits", () => { + expect(Math.floor(10 * MICRO_CREDITS_PER_MINUTE_PER_DAY)).toBe(33); + }); + + it("100 minutes -> 333 credits", () => { + expect(Math.floor(100 * MICRO_CREDITS_PER_MINUTE_PER_DAY)).toBe(333); + }); + + it("1000 minutes -> 3330 credits", () => { + expect(Math.floor(1000 * MICRO_CREDITS_PER_MINUTE_PER_DAY)).toBe(3330); + }); + + it("0.5 minutes -> 1 credit", () => { + expect(Math.floor(0.5 * MICRO_CREDITS_PER_MINUTE_PER_DAY)).toBe(1); + }); + }); +}); diff --git a/apps/web/__tests__/unit/developer-domain-validation.test.ts b/apps/web/__tests__/unit/developer-domain-validation.test.ts new file mode 100644 index 0000000000..836499d62f --- /dev/null +++ b/apps/web/__tests__/unit/developer-domain-validation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +const urlPattern = /^https?:\/\/[a-z0-9.-]+(:[0-9]+)?$/; + +function normalizeDomain(input: string): string { + return input.trim().toLowerCase(); +} + +describe("developer domain validation regex", () => { + describe("valid domains", () => { + const validDomains = [ + "https://example.com", + "https://myapp.com", + "http://localhost:3000", + "http://localhost", + "https://sub.domain.example.com", + "https://api.my-site.io", + "https://192.168.1.1", + "https://192.168.1.1:8080", + "http://localhost:8080", + "https://my-app.vercel.app", + "https://a.b.c.d.e.com", + "https://example.com:443", + "http://example.com:80", + "https://0.0.0.0:3000", + ]; + + for (const domain of validDomains) { + it(`matches ${domain}`, () => { + expect(urlPattern.test(domain)).toBe(true); + }); + } + }); + + describe("invalid domains", () => { + const invalidDomains: [string, string][] = [ + ["example.com", "no protocol"], + ["ftp://example.com", "wrong protocol"], + ["https://example.com/", "trailing slash"], + ["https://example.com/path", "has path"], + ["https://example.com?query=1", "has query"], + ["https://example.com#hash", "has hash"], + ["https://", "no hostname"], + ["https://EXAMPLE.COM", "uppercase"], + ["https://example.com:", "port separator but no port"], + ["https://example.com:abc", "non-numeric port"], + ["https://exam ple.com", "space in hostname"], + ["", "empty string"], + ["javascript:alert(1)", "XSS attempt"], + ["data:text/html,", "data URI"], + ]; + + for (const [domain, reason] of invalidDomains) { + it(`rejects ${domain || "(empty string)"} — ${reason}`, () => { + expect(urlPattern.test(domain)).toBe(false); + }); + } + }); + + describe("edge case: large port number", () => { + it("matches https://example.com:99999 since regex only checks for digits", () => { + expect(urlPattern.test("https://example.com:99999")).toBe(true); + }); + }); +}); + +describe("domain normalization", () => { + it("trims whitespace and lowercases the input", () => { + const raw = " https://Example.COM "; + const normalized = normalizeDomain(raw); + expect(normalized).toBe("https://example.com"); + }); + + it("produces a value that passes the regex after normalization", () => { + const raw = " https://Example.COM "; + const normalized = normalizeDomain(raw); + expect(urlPattern.test(normalized)).toBe(true); + }); + + it("lowercases mixed-case input before regex test", () => { + const raw = "https://MyApp.Vercel.App"; + const normalized = normalizeDomain(raw); + expect(normalized).toBe("https://myapp.vercel.app"); + expect(urlPattern.test(normalized)).toBe(true); + }); + + it("uppercase raw input fails regex directly but passes after normalization", () => { + const raw = "https://EXAMPLE.COM"; + expect(urlPattern.test(raw)).toBe(false); + expect(urlPattern.test(normalizeDomain(raw))).toBe(true); + }); +}); diff --git a/apps/web/__tests__/unit/developer-key-hash.test.ts b/apps/web/__tests__/unit/developer-key-hash.test.ts new file mode 100644 index 0000000000..5fa2d6dfc6 --- /dev/null +++ b/apps/web/__tests__/unit/developer-key-hash.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { hashKey } from "@/lib/developer-key-hash"; + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + NEXTAUTH_SECRET: "test-hmac-secret-for-unit-tests", + }), +})); + +describe("hashKey", () => { + it("produces different hashes for different keys", async () => { + const hash1 = await hashKey("key-one"); + const hash2 = await hashKey("key-two"); + expect(hash1).not.toBe(hash2); + }); + + it("is deterministic - same key always produces same hash", async () => { + const first = await hashKey("deterministic-test"); + const second = await hashKey("deterministic-test"); + const third = await hashKey("deterministic-test"); + expect(first).toBe(second); + expect(second).toBe(third); + }); + + it("returns exactly 64 hex characters (256 bits)", async () => { + const result = await hashKey("length-check"); + expect(result).toHaveLength(64); + }); + + it("returns lowercase hex only", async () => { + const result = await hashKey("case-check"); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + + it("produces valid hash for empty string", async () => { + const result = await hashKey(""); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + + it("hashes public key format (cpk_*) correctly", async () => { + const result = await hashKey("cpk_live_abc123def456"); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + + it("hashes secret key format (csk_*) correctly", async () => { + const result = await hashKey("csk_live_secret789xyz"); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + + it("hashes unicode characters correctly", async () => { + const result = await hashKey("héllo wörld 🌍"); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/apps/web/__tests__/unit/record-screen-mac-audio-blog-post.test.ts b/apps/web/__tests__/unit/record-screen-mac-audio-blog-post.test.ts deleted file mode 100644 index 6308191c9b..0000000000 --- a/apps/web/__tests__/unit/record-screen-mac-audio-blog-post.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -const postPath = join( - process.cwd(), - "content/blog/how-to-record-screen-on-mac-with-audio.mdx", -); -const postSource = readFileSync(postPath, "utf-8"); - -describe("how-to-record-screen-on-mac-with-audio blog post frontmatter", () => { - it("has a title field", () => { - expect(postSource).toContain("title:"); - }); - - it("title targets the mac screen recording with audio keyword", () => { - expect(postSource.toLowerCase()).toContain("record"); - expect(postSource.toLowerCase()).toContain("mac"); - expect(postSource.toLowerCase()).toContain("audio"); - }); - - it("has a description field", () => { - expect(postSource).toContain("description:"); - }); - - it("has a publishedAt date", () => { - expect(postSource).toMatch(/publishedAt:\s*["']\d{4}-\d{2}-\d{2}["']/); - }); - - it("has a category field", () => { - expect(postSource).toContain("category:"); - }); - - it("has an image field", () => { - expect(postSource).toContain("image:"); - }); - - it("has an author field", () => { - expect(postSource).toContain("author:"); - }); - - it("has a tags field", () => { - expect(postSource).toContain("tags:"); - }); -}); - -describe("how-to-record-screen-on-mac-with-audio blog post content", () => { - it("mentions Cap as the recommended tool", () => { - expect(postSource).toContain("Cap"); - }); - - it("explains system audio limitation on macOS", () => { - expect(postSource.toLowerCase()).toContain("system audio"); - }); - - it("covers the Cap method", () => { - expect(postSource).toContain("cap.so"); - }); - - it("covers the QuickTime + BlackHole method", () => { - expect(postSource).toContain("BlackHole"); - expect(postSource).toContain("QuickTime"); - }); - - it("mentions microphone recording", () => { - expect(postSource.toLowerCase()).toContain("microphone"); - }); - - it("includes a comparison section", () => { - expect(postSource.toLowerCase()).toContain("comparison"); - }); - - it("includes troubleshooting guidance", () => { - expect(postSource.toLowerCase()).toContain("fix"); - }); - - it("links to the download page", () => { - expect(postSource).toContain("cap.so/download"); - }); - - it("mentions macOS versions and Apple Silicon", () => { - expect(postSource.toLowerCase()).toContain("m-series"); - }); - - it("mentions OBS as an alternative", () => { - expect(postSource).toContain("OBS"); - }); - - it("has a tips section", () => { - expect(postSource.toLowerCase()).toContain("tips"); - }); -}); diff --git a/apps/web/__tests__/unit/record-screen-windows-blog-post.test.ts b/apps/web/__tests__/unit/record-screen-windows-blog-post.test.ts deleted file mode 100644 index 0227e0e507..0000000000 --- a/apps/web/__tests__/unit/record-screen-windows-blog-post.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -const postPath = join( - process.cwd(), - "content/blog/how-to-record-screen-on-windows.mdx", -); -const postSource = readFileSync(postPath, "utf-8"); - -describe("how-to-record-screen-on-windows blog post frontmatter", () => { - it("has a title field", () => { - expect(postSource).toContain("title:"); - }); - - it("title targets the windows screen recording keyword", () => { - expect(postSource.toLowerCase()).toContain("record"); - expect(postSource.toLowerCase()).toContain("windows"); - }); - - it("has a description field", () => { - expect(postSource).toContain("description:"); - }); - - it("has a publishedAt date", () => { - expect(postSource).toMatch(/publishedAt:\s*["']\d{4}-\d{2}-\d{2}["']/); - }); - - it("has a category field", () => { - expect(postSource).toContain("category:"); - }); - - it("has an image field", () => { - expect(postSource).toContain("image:"); - }); - - it("has an author field", () => { - expect(postSource).toContain("author:"); - }); - - it("has a tags field", () => { - expect(postSource).toContain("tags:"); - }); -}); - -describe("how-to-record-screen-on-windows blog post content", () => { - it("mentions Cap as the recommended tool", () => { - expect(postSource).toContain("Cap"); - }); - - it("covers Xbox Game Bar", () => { - expect(postSource).toContain("Xbox Game Bar"); - }); - - it("covers Snipping Tool", () => { - expect(postSource).toContain("Snipping Tool"); - }); - - it("covers OBS Studio", () => { - expect(postSource).toContain("OBS"); - }); - - it("mentions Windows 10 and Windows 11", () => { - expect(postSource).toContain("Windows 10"); - expect(postSource).toContain("Windows 11"); - }); - - it("includes a comparison section", () => { - expect(postSource.toLowerCase()).toContain("comparison"); - }); - - it("includes troubleshooting guidance", () => { - expect(postSource.toLowerCase()).toContain("troubleshoot"); - }); - - it("links to the download page", () => { - expect(postSource).toContain("cap.so/download"); - }); - - it("mentions system audio", () => { - expect(postSource.toLowerCase()).toContain("system audio"); - }); - - it("mentions microphone recording", () => { - expect(postSource.toLowerCase()).toContain("microphone"); - }); - - it("has a tips section", () => { - expect(postSource.toLowerCase()).toContain("tips"); - }); - - it("covers full desktop recording", () => { - expect(postSource.toLowerCase()).toContain("full desktop"); - }); -}); diff --git a/apps/web/actions/developers/add-domain.ts b/apps/web/actions/developers/add-domain.ts new file mode 100644 index 0000000000..a0afa2fe7a --- /dev/null +++ b/apps/web/actions/developers/add-domain.ts @@ -0,0 +1,44 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { nanoId } from "@cap/database/helpers"; +import { developerAppDomains, developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function addDeveloperDomain(appId: string, domain: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const trimmed = domain.trim().toLowerCase(); + if (!trimmed) throw new Error("Domain is required"); + + const urlPattern = /^https?:\/\/[a-z0-9.-]+(:[0-9]+)?$/; + if (!urlPattern.test(trimmed)) { + throw new Error("Domain must be a valid origin (e.g. https://myapp.com)"); + } + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + await db().insert(developerAppDomains).values({ + id: nanoId(), + appId, + domain: trimmed, + }); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/create-app.ts b/apps/web/actions/developers/create-app.ts new file mode 100644 index 0000000000..a1c083e674 --- /dev/null +++ b/apps/web/actions/developers/create-app.ts @@ -0,0 +1,71 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { encrypt } from "@cap/database/crypto"; +import { nanoId, nanoIdLong } from "@cap/database/helpers"; +import { + developerApiKeys, + developerApps, + developerCreditAccounts, +} from "@cap/database/schema"; +import { hashKey } from "@/lib/developer-key-hash"; + +export async function createDeveloperApp(data: { + name: string; + environment: "development" | "production"; +}) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + if (!data.name.trim()) throw new Error("App name is required"); + + const appId = nanoId(); + const publicKeyRaw = `cpk_${nanoIdLong()}`; + const secretKeyRaw = `csk_${nanoIdLong()}`; + const publicKeyHash = await hashKey(publicKeyRaw); + const secretKeyHash = await hashKey(secretKeyRaw); + + const encryptedPublicKey = await encrypt(publicKeyRaw); + const encryptedSecretKey = await encrypt(secretKeyRaw); + + await db().transaction(async (tx) => { + await tx.insert(developerApps).values({ + id: appId, + ownerId: user.id, + name: data.name.trim(), + environment: data.environment, + }); + + await tx.insert(developerApiKeys).values([ + { + id: nanoId(), + appId, + keyType: "public", + keyPrefix: publicKeyRaw.slice(0, 12), + keyHash: publicKeyHash, + encryptedKey: encryptedPublicKey, + }, + { + id: nanoId(), + appId, + keyType: "secret", + keyPrefix: secretKeyRaw.slice(0, 12), + keyHash: secretKeyHash, + encryptedKey: encryptedSecretKey, + }, + ]); + + await tx.insert(developerCreditAccounts).values({ + id: nanoId(), + appId, + ownerId: user.id, + }); + }); + + return { + appId, + publicKey: publicKeyRaw, + secretKey: secretKeyRaw, + }; +} diff --git a/apps/web/actions/developers/delete-app.ts b/apps/web/actions/developers/delete-app.ts new file mode 100644 index 0000000000..9b05d90b46 --- /dev/null +++ b/apps/web/actions/developers/delete-app.ts @@ -0,0 +1,46 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApiKeys, developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function deleteDeveloperApp(appId: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + await db().transaction(async (tx) => { + await tx + .update(developerApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(developerApiKeys.appId, appId), + isNull(developerApiKeys.revokedAt), + ), + ); + + await tx + .update(developerApps) + .set({ deletedAt: new Date() }) + .where(eq(developerApps.id, appId)); + }); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/delete-video.ts b/apps/web/actions/developers/delete-video.ts new file mode 100644 index 0000000000..b26e3817c2 --- /dev/null +++ b/apps/web/actions/developers/delete-video.ts @@ -0,0 +1,36 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApps, developerVideos } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function deleteDeveloperVideo(appId: string, videoId: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + await db() + .update(developerVideos) + .set({ deletedAt: new Date() }) + .where( + and(eq(developerVideos.id, videoId), eq(developerVideos.appId, appId)), + ); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/regenerate-keys.ts b/apps/web/actions/developers/regenerate-keys.ts new file mode 100644 index 0000000000..8ddb1db966 --- /dev/null +++ b/apps/web/actions/developers/regenerate-keys.ts @@ -0,0 +1,73 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { encrypt } from "@cap/database/crypto"; +import { nanoId, nanoIdLong } from "@cap/database/helpers"; +import { developerApiKeys, developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { hashKey } from "@/lib/developer-key-hash"; + +export async function regenerateDeveloperKeys(appId: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + const publicKeyRaw = `cpk_${nanoIdLong()}`; + const secretKeyRaw = `csk_${nanoIdLong()}`; + const publicKeyHash = await hashKey(publicKeyRaw); + const secretKeyHash = await hashKey(secretKeyRaw); + const encryptedPublicKey = await encrypt(publicKeyRaw); + const encryptedSecretKey = await encrypt(secretKeyRaw); + + await db().transaction(async (tx) => { + await tx + .update(developerApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(developerApiKeys.appId, appId), + isNull(developerApiKeys.revokedAt), + ), + ); + + await tx.insert(developerApiKeys).values([ + { + id: nanoId(), + appId, + keyType: "public", + keyPrefix: publicKeyRaw.slice(0, 12), + keyHash: publicKeyHash, + encryptedKey: encryptedPublicKey, + }, + { + id: nanoId(), + appId, + keyType: "secret", + keyPrefix: secretKeyRaw.slice(0, 12), + keyHash: secretKeyHash, + encryptedKey: encryptedSecretKey, + }, + ]); + }); + + revalidatePath("/dashboard/developers"); + return { + publicKey: publicKeyRaw, + secretKey: secretKeyRaw, + }; +} diff --git a/apps/web/actions/developers/remove-domain.ts b/apps/web/actions/developers/remove-domain.ts new file mode 100644 index 0000000000..e82e98bc59 --- /dev/null +++ b/apps/web/actions/developers/remove-domain.ts @@ -0,0 +1,38 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerAppDomains, developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function removeDeveloperDomain(appId: string, domainId: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + await db() + .delete(developerAppDomains) + .where( + and( + eq(developerAppDomains.id, domainId), + eq(developerAppDomains.appId, appId), + ), + ); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/update-app.ts b/apps/web/actions/developers/update-app.ts new file mode 100644 index 0000000000..5223f0b957 --- /dev/null +++ b/apps/web/actions/developers/update-app.ts @@ -0,0 +1,50 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function updateDeveloperApp(data: { + appId: string; + name?: string; + environment?: "development" | "production"; + logoUrl?: string | null; +}) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, data.appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + const updates: Partial = {}; + if (data.name !== undefined) { + const trimmed = data.name.trim(); + if (!trimmed) throw new Error("App name cannot be empty"); + updates.name = trimmed; + } + if (data.environment !== undefined) updates.environment = data.environment; + if (data.logoUrl !== undefined) updates.logoUrl = data.logoUrl; + + if (Object.keys(updates).length > 0) { + await db() + .update(developerApps) + .set(updates) + .where(eq(developerApps.id, data.appId)); + } + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/update-auto-topup.ts b/apps/web/actions/developers/update-auto-topup.ts new file mode 100644 index 0000000000..1d5fb37fc1 --- /dev/null +++ b/apps/web/actions/developers/update-auto-topup.ts @@ -0,0 +1,63 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApps, developerCreditAccounts } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function updateDeveloperAutoTopUp(data: { + appId: string; + enabled: boolean; + thresholdMicroCredits?: number; + amountCents?: number; +}) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, data.appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + if ( + data.thresholdMicroCredits !== undefined && + data.thresholdMicroCredits < 0 + ) { + throw new Error("Threshold must be non-negative"); + } + if ( + data.amountCents !== undefined && + (data.amountCents <= 0 || data.amountCents > 100_000) + ) { + throw new Error("Top-up amount must be between $0.01 and $1,000.00"); + } + + const updates: Partial = { + autoTopUpEnabled: data.enabled, + }; + + if (data.thresholdMicroCredits !== undefined) { + updates.autoTopUpThresholdMicroCredits = data.thresholdMicroCredits; + } + if (data.amountCents !== undefined) { + updates.autoTopUpAmountCents = data.amountCents; + } + + await db() + .update(developerCreditAccounts) + .set(updates) + .where(eq(developerCreditAccounts.appId, data.appId)); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/app/(docs)/docs/[[...slug]]/page.tsx b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx new file mode 100644 index 0000000000..2377500a6a --- /dev/null +++ b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx @@ -0,0 +1,71 @@ +import { buildEnv } from "@cap/env"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CustomMDX } from "@/components/mdx"; +import { extractHeadings, getDocBySlug } from "@/utils/docs"; +import { DocsBreadcrumbs } from "../_components/DocsBreadcrumbs"; +import { DocsPrevNext } from "../_components/DocsPrevNext"; +import { DocsTableOfContents } from "../_components/DocsTableOfContents"; + +interface DocPageProps { + params: Promise<{ slug?: string[] }>; +} + +export async function generateMetadata( + props: DocPageProps, +): Promise { + const params = await props.params; + const slug = params.slug?.join("/") ?? "introduction"; + const doc = getDocBySlug(slug); + if (!doc) return; + + const { title, summary: description, image } = doc.metadata; + const ogImage = image ? `${buildEnv.NEXT_PUBLIC_WEB_URL}${image}` : undefined; + + return { + title: `${title} - Cap Docs`, + description: description || title, + openGraph: { + title: `${title} - Cap Docs`, + description: description || title, + type: "article", + url: `${buildEnv.NEXT_PUBLIC_WEB_URL}/docs/${slug}`, + ...(ogImage && { images: [{ url: ogImage }] }), + }, + }; +} + +export default async function DocPage(props: DocPageProps) { + const params = await props.params; + const slug = params.slug?.join("/") ?? "introduction"; + const doc = getDocBySlug(slug); + + if (!doc) { + notFound(); + } + + const headings = extractHeadings(doc.content); + + return ( +
+
+ +
+

+ {doc.metadata.title} +

+ {doc.metadata.summary && ( +

{doc.metadata.summary}

+ )} +
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsBreadcrumbs.tsx b/apps/web/app/(docs)/docs/_components/DocsBreadcrumbs.tsx new file mode 100644 index 0000000000..21f10affd1 --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsBreadcrumbs.tsx @@ -0,0 +1,43 @@ +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { getBreadcrumbs } from "../docs-config"; + +interface DocsBreadcrumbsProps { + currentSlug: string; +} + +export function DocsBreadcrumbs({ currentSlug }: DocsBreadcrumbsProps) { + const breadcrumbs = getBreadcrumbs(currentSlug); + + return ( + + ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsHeader.tsx b/apps/web/app/(docs)/docs/_components/DocsHeader.tsx new file mode 100644 index 0000000000..0cdac10858 --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsHeader.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Logo } from "@cap/ui"; +import { ExternalLink, Menu, Search } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +function useOS() { + const [isMac, setIsMac] = useState(true); + + useEffect(() => { + setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0); + }, []); + + return { isMac }; +} + +export function DocsHeader() { + const { isMac } = useOS(); + + const handleSearchClick = () => { + window.dispatchEvent(new CustomEvent("open-docs-search")); + }; + + const handleMobileMenuClick = () => { + window.dispatchEvent(new CustomEvent("open-docs-mobile-menu")); + }; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + window.dispatchEvent(new CustomEvent("open-docs-search")); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + return ( +
+
+ + + + + / + + Docs + +
+ + + +
+ + + cap.so + + + + + GitHub + + + +
+
+ ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsMobileMenu.tsx b/apps/web/app/(docs)/docs/_components/DocsMobileMenu.tsx new file mode 100644 index 0000000000..f4429b4b4d --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsMobileMenu.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { X } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { docsConfig, type SidebarGroup } from "../docs-config"; + +export function DocsMobileMenu() { + const [isOpen, setIsOpen] = useState(false); + const pathname = usePathname(); + const prevPathname = useRef(pathname); + + useEffect(() => { + const handleOpen = () => setIsOpen(true); + window.addEventListener("open-docs-mobile-menu", handleOpen); + return () => + window.removeEventListener("open-docs-mobile-menu", handleOpen); + }, []); + + useEffect(() => { + if (prevPathname.current !== pathname) { + setIsOpen(false); + prevPathname.current = pathname; + } + }, [pathname]); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + const isActive = (slug: string) => { + return pathname === `/docs/${slug}` || pathname === `/docs/${slug}/`; + }; + + if (!isOpen) return null; + + return ( +
+ +
+ + + + ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsPrevNext.tsx b/apps/web/app/(docs)/docs/_components/DocsPrevNext.tsx new file mode 100644 index 0000000000..e41482a4bd --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsPrevNext.tsx @@ -0,0 +1,54 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { getAdjacentDocs } from "../docs-config"; + +interface DocsPrevNextProps { + currentSlug: string; +} + +export function DocsPrevNext({ currentSlug }: DocsPrevNextProps) { + const { prev, next } = getAdjacentDocs(currentSlug); + + if (!prev && !next) return null; + + return ( +
+ {prev ? ( + + +
+ + Previous + + + {prev.title} + +
+ + ) : ( +
+ )} + {next ? ( + +
+ + Next + + + {next.title} + +
+ + + ) : ( +
+ )} +
+ ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsSearch.tsx b/apps/web/app/(docs)/docs/_components/DocsSearch.tsx new file mode 100644 index 0000000000..77e7b18ab1 --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsSearch.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { FileText, Search } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; + +interface SearchItem { + slug: string; + title: string; + summary: string; + content: string; + group: string; +} + +interface DocsSearchProps { + searchIndex: SearchItem[]; +} + +interface GroupedResults { + group: string; + items: SearchItem[]; +} + +function groupResults(items: SearchItem[]): GroupedResults[] { + const map = new Map(); + for (const item of items) { + const existing = map.get(item.group); + if (existing) { + existing.push(item); + } else { + map.set(item.group, [item]); + } + } + return Array.from(map.entries()).map(([group, items]) => ({ + group, + items, + })); +} + +function truncateSummary(text: string, maxLength = 120): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trimEnd()}...`; +} + +export function DocsSearch({ searchIndex }: DocsSearchProps) { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const router = useRouter(); + const inputRef = useRef(null); + const resultsRef = useRef(null); + const activeItemRef = useRef(null); + const instanceId = useId(); + const resultsId = `${instanceId}-docs-search-results`; + const prevQueryRef = useRef(query); + const prevActiveIndexRef = useRef(activeIndex); + + const filteredResults = useMemo(() => { + if (!query.trim()) return []; + const lowerQuery = query.toLowerCase(); + return searchIndex.filter( + (item) => + item.title.toLowerCase().includes(lowerQuery) || + item.summary.toLowerCase().includes(lowerQuery) || + item.content.toLowerCase().includes(lowerQuery), + ); + }, [query, searchIndex]); + + const grouped = useMemo( + () => groupResults(filteredResults), + [filteredResults], + ); + + const flatResults = useMemo(() => grouped.flatMap((g) => g.items), [grouped]); + + const open = useCallback(() => { + setIsOpen(true); + setQuery(""); + setActiveIndex(0); + requestAnimationFrame(() => { + setIsAnimating(true); + inputRef.current?.focus(); + }); + }, []); + + const close = useCallback(() => { + setIsAnimating(false); + const timeout = setTimeout(() => { + setIsOpen(false); + setQuery(""); + setActiveIndex(0); + }, 150); + return () => clearTimeout(timeout); + }, []); + + const navigateTo = useCallback( + (slug: string) => { + close(); + router.push(`/docs/${slug}`); + }, + [close, router], + ); + + useEffect(() => { + const handleCustomEvent = () => open(); + window.addEventListener("open-docs-search", handleCustomEvent); + return () => + window.removeEventListener("open-docs-search", handleCustomEvent); + }, [open]); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + close(); + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => + prev < flatResults.length - 1 ? prev + 1 : 0, + ); + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => + prev > 0 ? prev - 1 : flatResults.length - 1, + ); + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + const selected = flatResults[activeIndex]; + if (selected) { + navigateTo(selected.slug); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, flatResults, activeIndex, close, navigateTo]); + + if (prevQueryRef.current !== query) { + prevQueryRef.current = query; + setActiveIndex(0); + } + + if (prevActiveIndexRef.current !== activeIndex) { + prevActiveIndexRef.current = activeIndex; + activeItemRef.current?.scrollIntoView({ block: "nearest" }); + } + + useEffect(() => { + if (isOpen) { + const scrollbarWidth = + window.innerWidth - document.documentElement.clientWidth; + document.documentElement.style.setProperty( + "--scrollbar-compensation", + `${scrollbarWidth}px`, + ); + document.body.style.paddingRight = `${scrollbarWidth}px`; + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + document.body.style.paddingRight = ""; + document.documentElement.style.removeProperty("--scrollbar-compensation"); + } + return () => { + document.body.style.overflow = ""; + document.body.style.paddingRight = ""; + document.documentElement.style.removeProperty("--scrollbar-compensation"); + }; + }, [isOpen]); + + if (!isOpen) return null; + + let flatIndex = -1; + + return ( +
+ + ); + })} +
+ ))} +
+ + {flatResults.length > 0 && ( +
+ + + ↑ + + + ↓ + + to navigate + + + + ↵ + + to select + +
+ )} +
+ + ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsSidebar.tsx b/apps/web/app/(docs)/docs/_components/DocsSidebar.tsx new file mode 100644 index 0000000000..1e028a3e20 --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsSidebar.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { docsConfig, type SidebarGroup } from "../docs-config"; + +export function DocsSidebar() { + const pathname = usePathname(); + + const isActive = (slug: string) => { + return pathname === `/docs/${slug}` || pathname === `/docs/${slug}/`; + }; + + return ( + + ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsTableOfContents.tsx b/apps/web/app/(docs)/docs/_components/DocsTableOfContents.tsx new file mode 100644 index 0000000000..7a677ed83c --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsTableOfContents.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface Heading { + level: number; + text: string; + slug: string; +} + +interface DocsTableOfContentsProps { + headings: Heading[]; +} + +export function DocsTableOfContents({ headings }: DocsTableOfContentsProps) { + const [activeSlug, setActiveSlug] = useState(""); + + useEffect(() => { + if (headings.length === 0) return; + + const slugs = headings.map((h) => h.slug); + const elements = slugs + .map((slug) => document.getElementById(slug)) + .filter(Boolean) as HTMLElement[]; + + if (elements.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + const visibleEntries = entries.filter((entry) => entry.isIntersecting); + if (visibleEntries.length > 0) { + const topEntry = visibleEntries.reduce((prev, curr) => + prev.boundingClientRect.top < curr.boundingClientRect.top + ? prev + : curr, + ); + setActiveSlug(topEntry.target.id); + } + }, + { + rootMargin: "-80px 0px -60% 0px", + threshold: 0, + }, + ); + + for (const el of elements) { + observer.observe(el); + } + + return () => observer.disconnect(); + }, [headings]); + + const filteredHeadings = headings.filter( + (h) => h.level === 2 || h.level === 3, + ); + + if (filteredHeadings.length === 0) return null; + + return ( +
+

+ On this page +

+ +
+ ); +} diff --git a/apps/web/app/(docs)/docs/docs-config.ts b/apps/web/app/(docs)/docs/docs-config.ts new file mode 100644 index 0000000000..48fa2b2d84 --- /dev/null +++ b/apps/web/app/(docs)/docs/docs-config.ts @@ -0,0 +1,89 @@ +export interface SidebarLink { + title: string; + slug: string; +} + +export interface SidebarGroup { + title: string; + items: SidebarLink[]; +} + +export const docsConfig = { + sidebar: [ + { + title: "Getting Started", + items: [ + { title: "Introduction", slug: "introduction" }, + { title: "Installation", slug: "installation" }, + { title: "Quickstart", slug: "quickstart" }, + ], + }, + { + title: "Recording", + items: [ + { title: "Instant Mode", slug: "recording/instant-mode" }, + { title: "Studio Mode", slug: "recording/studio-mode" }, + { title: "Camera & Microphone", slug: "recording/camera-and-mic" }, + { + title: "Keyboard Shortcuts", + slug: "recording/keyboard-shortcuts", + }, + ], + }, + { + title: "Sharing & Playback", + items: [ + { title: "Share a Cap", slug: "sharing/share-a-cap" }, + { title: "Embeds", slug: "sharing/embeds" }, + { title: "Comments", slug: "sharing/comments" }, + { title: "Analytics", slug: "sharing/analytics" }, + ], + }, + { + title: "Self-hosting", + items: [ + { title: "Overview", slug: "self-hosting" }, + { title: "S3: AWS", slug: "s3-config/aws-s3" }, + { title: "S3: Cloudflare R2", slug: "s3-config/cloudflare-r2" }, + ], + }, + { + title: "API & Developers", + items: [ + { title: "REST API", slug: "api/rest-api" }, + { title: "Webhooks", slug: "api/webhooks" }, + ], + }, + { + title: "Legal", + items: [{ title: "Commercial License", slug: "commercial-license" }], + }, + ] satisfies SidebarGroup[], +}; + +export function flattenSidebar(): SidebarLink[] { + return docsConfig.sidebar.flatMap((group) => group.items); +} + +export function getAdjacentDocs(currentSlug: string) { + const flat = flattenSidebar(); + const idx = flat.findIndex((item) => item.slug === currentSlug); + return { + prev: idx > 0 ? flat[idx - 1] : null, + next: idx < flat.length - 1 ? flat[idx + 1] : null, + }; +} + +export function getBreadcrumbs(currentSlug: string) { + for (const group of docsConfig.sidebar) { + const item = group.items.find((i) => i.slug === currentSlug); + if (item) { + return [ + { title: "Docs", slug: "" }, + { title: group.title, slug: group.items[0]?.slug ?? "" }, + { title: item.title, slug: item.slug }, + ]; + } + } + return [{ title: "Docs", slug: "" }]; +} diff --git a/apps/web/app/(docs)/docs/layout.tsx b/apps/web/app/(docs)/docs/layout.tsx new file mode 100644 index 0000000000..854ba586a7 --- /dev/null +++ b/apps/web/app/(docs)/docs/layout.tsx @@ -0,0 +1,25 @@ +import type { PropsWithChildren } from "react"; +import { getDocSearchIndex } from "@/utils/docs"; +import { DocsHeader } from "./_components/DocsHeader"; +import { DocsMobileMenu } from "./_components/DocsMobileMenu"; +import { DocsSearch } from "./_components/DocsSearch"; +import { DocsSidebar } from "./_components/DocsSidebar"; +import { docsConfig } from "./docs-config"; + +export default function DocsLayout(props: PropsWithChildren) { + const searchIndex = getDocSearchIndex(docsConfig.sidebar); + + return ( +
+ + +
+ + +
{props.children}
+
+
+ ); +} diff --git a/apps/web/app/(docs)/layout.tsx b/apps/web/app/(docs)/layout.tsx new file mode 100644 index 0000000000..3d1a59d321 --- /dev/null +++ b/apps/web/app/(docs)/layout.tsx @@ -0,0 +1,5 @@ +import type { PropsWithChildren } from "react"; + +export default function DocsRootLayout(props: PropsWithChildren) { + return <>{props.children}; +} diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index 5ae70ee82c..faa2a7f135 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -12,6 +12,7 @@ import type { Spaces, UserPreferences, } from "./dashboard-data"; +import type { DeveloperApp } from "./developers/developer-data"; type SharedContext = { organizationData: Organization[] | null; @@ -31,6 +32,9 @@ type SharedContext = { setUpgradeModalOpen: (open: boolean) => void; referClickedState: boolean; setReferClickedStateHandler: (referClicked: boolean) => void; + isDeveloperSection: boolean; + developerApps: DeveloperApp[] | null; + setDeveloperApps: (apps: DeveloperApp[] | null) => void; }; type ITheme = "light" | "dark"; @@ -83,7 +87,11 @@ export function DashboardContexts({ ); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [referClickedState, setReferClickedState] = useState(referClicked); + const [developerApps, setDeveloperApps] = useState( + null, + ); const pathname = usePathname(); + const isDeveloperSection = pathname.startsWith("/dashboard/developers"); // Calculate user's spaces (both owned and member of) const userSpaces = @@ -176,6 +184,9 @@ export function DashboardContexts({ setUpgradeModalOpen, referClickedState, setReferClickedStateHandler, + isDeveloperSection, + developerApps, + setDeveloperApps, }} > {children} diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Code.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Code.tsx new file mode 100644 index 0000000000..5204730a5a --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Code.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { cn } from "@/lib/utils"; + +export interface CodeIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface CodeIconProps extends HTMLAttributes { + size?: number; +} + +const CodeIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("animate"); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + + return ( +
+ + + + +
+ ); + }, +); + +CodeIcon.displayName = "CodeIcon"; + +export default CodeIcon; diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts index a78bba9d4f..f3570be3a5 100644 --- a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts @@ -4,6 +4,7 @@ import ChartLineIcon from "./ChartLine"; import MessageCircleMoreIcon from "./Chat"; import ChatIcon from "./Chat"; import ClapIcon from "./Clap"; +import CodeIcon from "./Code"; import CogIcon from "./Cog"; import DownloadIcon from "./Download"; import HomeIcon from "./Home"; @@ -18,6 +19,7 @@ export { ArrowUpIcon, CapIcon, MessageCircleMoreIcon, + CodeIcon, CogIcon, DownloadIcon, HomeIcon, diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx index 62551931b9..0d43dcfcc4 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx @@ -3,15 +3,17 @@ import { Button, Logo } from "@cap/ui"; import clsx from "clsx"; import { motion } from "framer-motion"; import { useDetectPlatform } from "hooks/useDetectPlatform"; -import { ChevronRight } from "lucide-react"; +import { ArrowLeft, ChevronRight } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import { Tooltip } from "@/components/Tooltip"; import { useDashboardContext } from "../../Contexts"; +import { DeveloperSidebarContent } from "../../developers/_components/DeveloperSidebarContent"; import AdminNavItems from "./Items"; export const DesktopNav = () => { - const { toggleSidebarCollapsed, sidebarCollapsed } = useDashboardContext(); + const { toggleSidebarCollapsed, sidebarCollapsed, isDeveloperSection } = + useDashboardContext(); const { platform } = useDetectPlatform(); const cmdSymbol = platform === "macos" ? "⌘" : "Ctrl"; @@ -47,16 +49,33 @@ export const DesktopNav = () => { >
- - - + > + + {!sidebarCollapsed && ( + Dashboard + )} + + ) : ( + + + + )} {
- + {isDeveloperSection ? ( + + ) : ( + + )}
diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 160105bbc1..424e47f807 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -38,6 +38,7 @@ import { useDashboardContext } from "../../Contexts"; import { CapIcon, ChartLineIcon, + CodeIcon, CogIcon, ImportIcon, RecordIcon, @@ -55,6 +56,12 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const [open, setOpen] = useState(false); const { user, sidebarCollapsed, userCapsCount } = useDashboardContext(); + const DEVELOPER_DASHBOARD_ALLOWED_EMAILS = ["richie@cap.so"]; + + const showDeveloperDashboard = + buildEnv.NEXT_PUBLIC_IS_CAP && + DEVELOPER_DASHBOARD_ALLOWED_EMAILS.includes(user.email); + const manageNavigation = [ { name: "My Caps", @@ -90,6 +97,18 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { icon: , subNav: [], }, + ...(showDeveloperDashboard + ? [ + { + name: "Developers", + href: `/dashboard/developers`, + ownerOnly: true, + matchChildren: true, + icon: , + subNav: [] as { name: string; href: string }[], + }, + ] + : []), ]; const [dialogOpen, setDialogOpen] = useState(false); diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index 6af7c64f02..c634a876c6 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -47,7 +47,8 @@ import type { DownloadIconHandle } from "../AnimatedIcons/Download"; import type { ReferIconHandle } from "../AnimatedIcons/Refer"; const Top = () => { - const { activeSpace, anyNewNotifications } = useDashboardContext(); + const { activeSpace, anyNewNotifications, isDeveloperSection } = + useDashboardContext(); const [toggleNotifications, setToggleNotifications] = useState(false); const bellRef = useRef(null); const { theme, setThemeHandler } = useTheme(); @@ -68,6 +69,10 @@ const Top = () => { "/dashboard/analytics": "Analytics", [`/dashboard/folder/${params.id}`]: "Caps", [`/dashboard/analytics/s/${params.id}`]: "Analytics: Cap video title", + "/dashboard/developers": "Developers", + "/dashboard/developers/apps": "Developer Apps", + "/dashboard/developers/usage": "Developer Usage", + "/dashboard/developers/credits": "Developer Credits", }; const title = activeSpace ? activeSpace.name : titles[pathname] || ""; @@ -160,20 +165,22 @@ const Top = () => { {toggleNotifications && } -
{ - if (document.startViewTransition) { - document.startViewTransition(() => { + {!isDeveloperSection && ( +
{ + if (document.startViewTransition) { + document.startViewTransition(() => { + setThemeHandler(theme === "light" ? "dark" : "light"); + }); + } else { setThemeHandler(theme === "light" ? "dark" : "light"); - }); - } else { - setThemeHandler(theme === "light" ? "dark" : "light"); - } - }} - className="hidden justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 lg:flex hover:bg-gray-5 size-9" - > - -
+ } + }} + className="hidden justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 lg:flex hover:bg-gray-5 size-9" + > + +
+ )} diff --git a/apps/web/app/(org)/dashboard/developers/DevelopersContext.tsx b/apps/web/app/(org)/dashboard/developers/DevelopersContext.tsx new file mode 100644 index 0000000000..fb602b8405 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/DevelopersContext.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { createContext, type ReactNode, useContext } from "react"; +import type { DeveloperApp } from "./developer-data"; + +type DevelopersContextType = { + apps: DeveloperApp[]; +}; + +const DevelopersContext = createContext({ + apps: [], +}); + +export const useDevelopersContext = () => useContext(DevelopersContext); + +export function DevelopersProvider({ + children, + apps, +}: { + children: ReactNode; + apps: DeveloperApp[]; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/ApiKeyDisplay.tsx b/apps/web/app/(org)/dashboard/developers/_components/ApiKeyDisplay.tsx new file mode 100644 index 0000000000..4a8b8922b6 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/ApiKeyDisplay.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Label } from "@cap/ui"; +import { Check, Copy, Eye, EyeOff } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +export function ApiKeyDisplay({ + label, + value, + sensitive = false, +}: { + label: string; + value: string; + sensitive?: boolean; +}) { + const [visible, setVisible] = useState(!sensitive); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + toast.success("Copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + }; + + const displayValue = visible + ? value + : `${value.slice(0, 8)}${"•".repeat(20)}`; + + return ( +
+ +
+ + {displayValue} + + {sensitive && ( + + )} + +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/AppCard.tsx b/apps/web/app/(org)/dashboard/developers/_components/AppCard.tsx new file mode 100644 index 0000000000..448bca6378 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/AppCard.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; +import type { DeveloperApp } from "../developer-data"; +import { EnvironmentBadge } from "./EnvironmentBadge"; + +export function AppCard({ app }: { app: DeveloperApp }) { + return ( + +
+
+

{app.name}

+ +
+ +
+
+ {app.videoCount} videos + + {app.creditAccount + ? `$${((app.creditAccount.balanceMicroCredits ?? 0) / 100_000).toFixed(2)} credits` + : "$0.00 credits"} + +
+ + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/CreateAppDialog.tsx b/apps/web/app/(org)/dashboard/developers/_components/CreateAppDialog.tsx new file mode 100644 index 0000000000..8c7e3f4aa8 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/CreateAppDialog.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { createDeveloperApp } from "@/actions/developers/create-app"; +import { ApiKeyDisplay } from "./ApiKeyDisplay"; + +export function CreateAppDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const router = useRouter(); + const appNameId = useId(); + const [step, setStep] = useState<"create" | "keys">("create"); + const [name, setName] = useState(""); + const [environment, setEnvironment] = useState<"development" | "production">( + "development", + ); + const [keys, setKeys] = useState<{ + publicKey: string; + secretKey: string; + } | null>(null); + + const createMutation = useMutation({ + mutationFn: () => createDeveloperApp({ name, environment }), + onSuccess: (result) => { + setKeys({ + publicKey: result.publicKey, + secretKey: result.secretKey, + }); + setStep("keys"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to create app", + ); + }, + }); + + const handleClose = () => { + setStep("create"); + setName(""); + setEnvironment("development"); + setKeys(null); + onOpenChange(false); + }; + + return ( + + + {step === "create" && ( + <> + + Create Developer App + +
+
+ + setName(e.target.value)} + placeholder="My App" + /> +
+
+ +
+ + +
+
+
+ + + + + + )} + {step === "keys" && keys && ( + <> + + API Keys Created + +
+

+ Save your secret key now. You won't be able to see it again. +

+ + +
+ + + + + )} +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/CreditTransactionTable.tsx b/apps/web/app/(org)/dashboard/developers/_components/CreditTransactionTable.tsx new file mode 100644 index 0000000000..cc7bdbf800 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/CreditTransactionTable.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { DeveloperTransaction } from "../developer-data"; + +const typeLabels: Record = { + topup: "Top Up", + video_create: "Recording", + storage_daily: "Storage", + refund: "Refund", + adjustment: "Adjustment", +}; + +export function CreditTransactionTable({ + transactions, +}: { + transactions: DeveloperTransaction[]; +}) { + if (transactions.length === 0) { + return ( +

+ No transactions yet +

+ ); + } + + return ( +
+ + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + ))} + +
+ Type + + Amount + + Balance + + Date +
+ {typeLabels[tx.type] ?? tx.type} + = 0 ? "text-green-400" : "text-red-400" + }`} + > + {tx.amountMicroCredits >= 0 ? "+" : ""}$ + {(Math.abs(tx.amountMicroCredits) / 100_000).toFixed(4)} + + ${(tx.balanceAfterMicroCredits / 100_000).toFixed(2)} + + {new Date(tx.createdAt).toLocaleDateString()} +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarContent.tsx b/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarContent.tsx new file mode 100644 index 0000000000..cc6c3b6332 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarContent.tsx @@ -0,0 +1,214 @@ +"use client"; + +import clsx from "clsx"; +import { motion } from "framer-motion"; +import { + BarChart3, + Box, + CreditCard, + Globe, + Key, + Settings, + Video, +} from "lucide-react"; +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { Tooltip } from "@/components/Tooltip"; +import { useDashboardContext } from "../../Contexts"; +import { EnvironmentBadge } from "./EnvironmentBadge"; + +const mainNav = [ + { name: "Apps", href: "/dashboard/developers/apps", icon: Box }, + { name: "Usage", href: "/dashboard/developers/usage", icon: BarChart3 }, + { + name: "Credits", + href: "/dashboard/developers/credits", + icon: CreditCard, + }, +]; + +const appNav = [ + { name: "Settings", href: "settings", icon: Settings }, + { name: "API Keys", href: "api-keys", icon: Key }, + { name: "Domains", href: "domains", icon: Globe }, + { name: "Videos", href: "videos", icon: Video }, +]; + +export function DeveloperSidebarContent() { + const pathname = usePathname(); + const params = useParams<{ appId?: string }>(); + const { sidebarCollapsed, developerApps } = useDashboardContext(); + + const currentApp = + params.appId && developerApps + ? developerApps.find((a) => a.id === params.appId) + : null; + + const basePath = params.appId + ? `/dashboard/developers/apps/${params.appId}` + : null; + + const isActive = (href: string) => + pathname === href || pathname.startsWith(`${href}/`); + + return ( + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarRegistrar.tsx b/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarRegistrar.tsx new file mode 100644 index 0000000000..a544d80928 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarRegistrar.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; +import { useDashboardContext } from "../../Contexts"; +import type { DeveloperApp } from "../developer-data"; + +export function DeveloperSidebarRegistrar({ apps }: { apps: DeveloperApp[] }) { + const { setDeveloperApps } = useDashboardContext(); + + useEffect(() => { + setDeveloperApps(apps); + return () => { + setDeveloperApps(null); + }; + }, [apps, setDeveloperApps]); + + return null; +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/DeveloperThemeForcer.tsx b/apps/web/app/(org)/dashboard/developers/_components/DeveloperThemeForcer.tsx new file mode 100644 index 0000000000..d57578714e --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/DeveloperThemeForcer.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Cookies from "js-cookie"; +import { useEffect, useRef } from "react"; +import { useTheme } from "../../Contexts"; + +export function DeveloperThemeForcer({ + children, +}: { + children: React.ReactNode; +}) { + const { theme, setThemeHandler } = useTheme(); + const previousTheme = useRef<"light" | "dark">( + (Cookies.get("theme") as "light" | "dark") ?? "light", + ); + + useEffect(() => { + if (theme !== "dark") { + setThemeHandler("dark"); + } + }, [theme, setThemeHandler]); + + useEffect(() => { + const saved = previousTheme.current; + return () => { + if (saved !== "dark") { + document.body.className = saved; + Cookies.set("theme", saved, { expires: 365 }); + } + }; + }, []); + + return <>{children}; +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/DomainRow.tsx b/apps/web/app/(org)/dashboard/developers/_components/DomainRow.tsx new file mode 100644 index 0000000000..91672dda3f --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/DomainRow.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { removeDeveloperDomain } from "@/actions/developers/remove-domain"; + +export function DomainRow({ + appId, + domainId, + domain, +}: { + appId: string; + domainId: string; + domain: string; +}) { + const router = useRouter(); + const removeMutation = useMutation({ + mutationFn: () => removeDeveloperDomain(appId, domainId), + onSuccess: () => { + toast.success("Domain removed"); + router.refresh(); + }, + onError: () => { + toast.error("Failed to remove domain"); + }, + }); + + return ( +
+ {domain} + +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/EnvironmentBadge.tsx b/apps/web/app/(org)/dashboard/developers/_components/EnvironmentBadge.tsx new file mode 100644 index 0000000000..fe913b8376 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/EnvironmentBadge.tsx @@ -0,0 +1,24 @@ +import clsx from "clsx"; + +export function EnvironmentBadge({ + environment, + size = "sm", +}: { + environment: string; + size?: "sm" | "xs"; +}) { + const isProduction = environment === "production"; + return ( + + {isProduction ? "prod" : "dev"} + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/StatBox.tsx b/apps/web/app/(org)/dashboard/developers/_components/StatBox.tsx new file mode 100644 index 0000000000..3a271e5f5c --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/StatBox.tsx @@ -0,0 +1,21 @@ +"use client"; + +export function StatBox({ + label, + value, + subtext, +}: { + label: string; + value: string | number; + subtext?: string; +}) { + return ( +
+ {label} + + {value} + + {subtext && {subtext}} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/AppsListClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/AppsListClient.tsx new file mode 100644 index 0000000000..d8d4f72a57 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/AppsListClient.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Button } from "@cap/ui"; +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { AppCard } from "../_components/AppCard"; +import { CreateAppDialog } from "../_components/CreateAppDialog"; +import { useDevelopersContext } from "../DevelopersContext"; + +export function AppsListClient() { + const { apps } = useDevelopersContext(); + const [createOpen, setCreateOpen] = useState(false); + + return ( + <> +
+

Your Apps

+ +
+ + {apps.length === 0 ? ( +
+

No apps yet

+

+ Create your first app to get started with the Cap Developer SDK +

+ +
+ ) : ( +
+ {apps.map((app) => ( + + ))} +
+ )} + + + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/ApiKeysClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/ApiKeysClient.tsx new file mode 100644 index 0000000000..f38663a83f --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/ApiKeysClient.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { Button, Card, CardHeader, CardTitle } from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { RefreshCw } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { regenerateDeveloperKeys } from "@/actions/developers/regenerate-keys"; +import { ApiKeyDisplay } from "../../../_components/ApiKeyDisplay"; +import { useDevelopersContext } from "../../../DevelopersContext"; + +export function ApiKeysClient() { + const { appId } = useParams<{ appId: string }>(); + const { apps } = useDevelopersContext(); + const app = apps.find((a) => a.id === appId); + const router = useRouter(); + + const [newKeys, setNewKeys] = useState<{ + publicKey: string; + secretKey: string; + } | null>(null); + const [confirmRegenerate, setConfirmRegenerate] = useState(false); + + const regenerateMutation = useMutation({ + mutationFn: () => regenerateDeveloperKeys(appId), + onSuccess: (result) => { + setNewKeys(result); + setConfirmRegenerate(false); + toast.success("Keys regenerated"); + router.refresh(); + }, + onError: () => toast.error("Failed to regenerate keys"), + }); + + if (!app) { + return

App not found

; + } + + const publicKey = app.apiKeys.find((k) => k.keyType === "public"); + const secretKey = app.apiKeys.find((k) => k.keyType === "secret"); + + return ( +
+ {newKeys && ( + +

+ New keys generated. Save your secret key now! +

+
+ + +
+
+ )} + + + + Current Keys + +
+ {publicKey && ( + + )} + {secretKey && ( +
+ + Secret Key + + + {"•".repeat(24)} + +

+ Regenerate to reveal a new secret key +

+
+ )} +
+
+ + + + Regenerate Keys + +
+ {!confirmRegenerate ? ( + + ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/page.tsx new file mode 100644 index 0000000000..e215a7631e --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { ApiKeysClient } from "./ApiKeysClient"; + +export const metadata: Metadata = { + title: "API Keys — Cap", +}; + +export default async function ApiKeysPage() { + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/DomainsClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/DomainsClient.tsx new file mode 100644 index 0000000000..0ccce19d3d --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/DomainsClient.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { + Button, + Card, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { AlertTriangle, Plus } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { addDeveloperDomain } from "@/actions/developers/add-domain"; +import { DomainRow } from "../../../_components/DomainRow"; +import { useDevelopersContext } from "../../../DevelopersContext"; + +export function DomainsClient() { + const { appId } = useParams<{ appId: string }>(); + const { apps } = useDevelopersContext(); + const app = apps.find((a) => a.id === appId); + const router = useRouter(); + const domainInputId = useId(); + const [newDomain, setNewDomain] = useState(""); + + const addMutation = useMutation({ + mutationFn: () => addDeveloperDomain(appId, newDomain), + onSuccess: () => { + setNewDomain(""); + toast.success("Domain added"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to add domain", + ); + }, + }); + + if (!app) { + return

App not found

; + } + + return ( +
+ {app.environment === "development" && ( +
+ + Development apps allow all localhost origins automatically. +
+ )} + + + + Allowed Domains + + Restrict which domains can use your public API key. + + +
{ + e.preventDefault(); + addMutation.mutate(); + }} + className="flex gap-2 items-end mt-4" + > +
+ + setNewDomain(e.target.value)} + placeholder="https://myapp.com" + /> +
+ +
+ + {app.domains.length > 0 && ( +
+ {app.domains.map((d) => ( + + ))} +
+ )} + + {app.domains.length === 0 && ( +

+ No domains configured +

+ )} +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/page.tsx new file mode 100644 index 0000000000..db25636b31 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { DomainsClient } from "./DomainsClient"; + +export const metadata: Metadata = { + title: "Allowed Domains — Cap", +}; + +export default async function DomainsPage() { + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/layout.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/layout.tsx new file mode 100644 index 0000000000..388507f634 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { EnvironmentBadge } from "../../_components/EnvironmentBadge"; +import { useDevelopersContext } from "../../DevelopersContext"; + +export default function AppDetailLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { appId } = useParams<{ appId: string }>(); + const { apps } = useDevelopersContext(); + const app = apps.find((a) => a.id === appId); + + return ( +
+
+

+ {app?.name ?? "App"} +

+ {app && } +
+ {children} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/AppSettingsClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/AppSettingsClient.tsx new file mode 100644 index 0000000000..43bddcd986 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/AppSettingsClient.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { + Button, + Card, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { Trash2 } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { deleteDeveloperApp } from "@/actions/developers/delete-app"; +import { updateDeveloperApp } from "@/actions/developers/update-app"; +import { useDevelopersContext } from "../../../DevelopersContext"; + +export function AppSettingsClient() { + const { appId } = useParams<{ appId: string }>(); + const { apps } = useDevelopersContext(); + const app = apps.find((a) => a.id === appId); + const router = useRouter(); + const nameInputId = useId(); + + const [name, setName] = useState(app?.name ?? ""); + const [environment, setEnvironment] = useState( + app?.environment ?? "development", + ); + const [confirmDelete, setConfirmDelete] = useState(false); + + const updateMutation = useMutation({ + mutationFn: () => + updateDeveloperApp({ + appId, + name, + environment: environment as "development" | "production", + }), + onSuccess: () => { + toast.success("App updated"); + router.refresh(); + }, + onError: () => toast.error("Failed to update app"), + }); + + const deleteMutation = useMutation({ + mutationFn: () => deleteDeveloperApp(appId), + onSuccess: () => { + toast.success("App deleted"); + router.push("/dashboard/developers/apps"); + router.refresh(); + }, + onError: () => toast.error("Failed to delete app"), + }); + + if (!app) { + return

App not found

; + } + + return ( +
+ + + General + + Update your app name and environment. + + +
{ + e.preventDefault(); + updateMutation.mutate(); + }} + className="flex flex-col gap-4 mt-4" + > +
+ + setName(e.target.value)} + /> +
+
+ +
+ + +
+
+ +
+
+ + + + Danger Zone + + Deleting an app will revoke all API keys and stop all SDK + integrations. + + +
+ {!confirmDelete ? ( + + ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/page.tsx new file mode 100644 index 0000000000..8b8b6684f5 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { AppSettingsClient } from "./AppSettingsClient"; + +export const metadata: Metadata = { + title: "App Settings — Cap", +}; + +export default async function AppSettingsPage() { + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/VideosClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/VideosClient.tsx new file mode 100644 index 0000000000..9675def821 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/VideosClient.tsx @@ -0,0 +1,138 @@ +"use client"; + +import type { developerVideos } from "@cap/database/schema"; +import { + Button, + Card, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { Search, Trash2 } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { deleteDeveloperVideo } from "@/actions/developers/delete-video"; + +type Video = typeof developerVideos.$inferSelect; + +export function VideosClient({ + appId, + videos, +}: { + appId: string; + videos: Video[]; +}) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [userIdFilter, setUserIdFilter] = useState( + searchParams.get("userId") ?? "", + ); + + const handleFilter = () => { + const params = new URLSearchParams(); + if (userIdFilter.trim()) params.set("userId", userIdFilter.trim()); + router.push( + `/dashboard/developers/apps/${appId}/videos?${params.toString()}`, + ); + }; + + return ( +
+ + + Videos + + Videos recorded through the SDK for this app. + + + +
+
+ setUserIdFilter(e.target.value)} + placeholder="Filter by user ID..." + /> +
+ +
+ + {videos.length === 0 ? ( +

+ No videos found +

+ ) : ( +
+ + + + + + + + + + + {videos.map((video) => ( + + ))} + +
+ Name + + User ID + + Duration + + Created + +
+
+ )} +
+
+ ); +} + +function VideoRow({ video, appId }: { video: Video; appId: string }) { + const router = useRouter(); + const deleteMutation = useMutation({ + mutationFn: () => deleteDeveloperVideo(appId, video.id), + onSuccess: () => { + toast.success("Video deleted"); + router.refresh(); + }, + onError: () => toast.error("Failed to delete video"), + }); + + return ( + + {video.name} + + {video.externalUserId ?? "\u2014"} + + + {video.duration ? `${(video.duration / 60).toFixed(1)}m` : "\u2014"} + + + {new Date(video.createdAt).toLocaleDateString()} + + + + + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/page.tsx new file mode 100644 index 0000000000..7b810b935b --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/page.tsx @@ -0,0 +1,44 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getDeveloperAppVideos } from "../../../developer-data"; +import { VideosClient } from "./VideosClient"; + +export const metadata: Metadata = { + title: "Developer Videos — Cap", +}; + +export default async function VideosPage({ + params, + searchParams, +}: { + params: Promise<{ appId: string }>; + searchParams: Promise<{ userId?: string }>; +}) { + const user = await getCurrentUser(); + if (!user) redirect("/auth/signin"); + + const { appId } = await params; + const { userId } = await searchParams; + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) redirect("/dashboard/developers/apps"); + + const videos = await getDeveloperAppVideos(appId, { userId }); + + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/page.tsx new file mode 100644 index 0000000000..0de507cd26 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { AppsListClient } from "./AppsListClient"; + +export const metadata: Metadata = { + title: "Developer Apps — Cap", +}; + +export default async function AppsPage() { + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/credits/CreditsClient.tsx b/apps/web/app/(org)/dashboard/developers/credits/CreditsClient.tsx new file mode 100644 index 0000000000..a3b7db2ebd --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/credits/CreditsClient.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { + Button, + Card, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { CreditTransactionTable } from "../_components/CreditTransactionTable"; +import { StatBox } from "../_components/StatBox"; +import { useDevelopersContext } from "../DevelopersContext"; +import type { DeveloperTransaction } from "../developer-data"; + +const presets = [ + { label: "$10", cents: 1000 }, + { label: "$25", cents: 2500 }, + { label: "$50", cents: 5000 }, +]; + +export function CreditsClient({ + transactions, +}: { + transactions: DeveloperTransaction[]; +}) { + const { apps } = useDevelopersContext(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [selectedApp, setSelectedApp] = useState(apps[0]?.id ?? ""); + const [customAmount, setCustomAmount] = useState(""); + + const app = apps.find((a) => a.id === selectedApp); + const balance = app?.creditAccount?.balanceMicroCredits ?? 0; + + useEffect(() => { + if (searchParams.get("purchase") === "success") { + toast.success("Credits purchased successfully!"); + router.replace("/dashboard/developers/credits"); + } + }, [searchParams, router]); + + const purchaseMutation = useMutation({ + mutationFn: async (amountCents: number) => { + const res = await fetch("/api/developer/credits/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ appId: selectedApp, amountCents }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error ?? "Failed to start checkout"); + } + + const { url } = await res.json(); + window.location.href = url; + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to purchase credits", + ); + }, + }); + + return ( +
+
+

Credits

+ {apps.length > 1 && ( + + )} +
+ +
+ + + +
+ +
+ + + Purchase Credits + Add credits to your account. + +
+ {presets.map((preset) => ( + + ))} +
+
+ setCustomAmount(e.target.value)} + placeholder="$" + className="w-20" + /> + +
+
+ + + + + Auto Top-Up + + Coming soon + + + + Automatically add $25 when balance drops below $5. + + +
+ +
+
+
+ + + + Transaction History + +
+ +
+
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/credits/page.tsx b/apps/web/app/(org)/dashboard/developers/credits/page.tsx new file mode 100644 index 0000000000..9dedda42d0 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/credits/page.tsx @@ -0,0 +1,35 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerCreditTransactions } from "@cap/database/schema"; +import { desc, inArray } from "drizzle-orm"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getDeveloperApps } from "../developer-data"; +import { CreditsClient } from "./CreditsClient"; + +export const metadata: Metadata = { + title: "Developer Credits — Cap", +}; + +export default async function CreditsPage() { + const user = await getCurrentUser(); + if (!user) redirect("/auth/signin"); + + const apps = await getDeveloperApps(user); + + const accountIds = apps + .map((a) => a.creditAccount?.id) + .filter((id): id is string => Boolean(id)); + + const transactions = + accountIds.length > 0 + ? await db() + .select() + .from(developerCreditTransactions) + .where(inArray(developerCreditTransactions.accountId, accountIds)) + .orderBy(desc(developerCreditTransactions.createdAt)) + .limit(50) + : []; + + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/developer-data.ts b/apps/web/app/(org)/dashboard/developers/developer-data.ts new file mode 100644 index 0000000000..13661f18e4 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/developer-data.ts @@ -0,0 +1,181 @@ +import { db } from "@cap/database"; +import type { userSelectProps } from "@cap/database/auth/session"; +import { decrypt } from "@cap/database/crypto"; +import { + developerApiKeys, + developerAppDomains, + developerApps, + developerCreditAccounts, + developerCreditTransactions, + developerVideos, +} from "@cap/database/schema"; +import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; + +export type DeveloperApiKey = Pick< + typeof developerApiKeys.$inferSelect, + "id" | "keyType" | "keyPrefix" | "createdAt" | "revokedAt" +> & { + fullKey?: string; +}; + +export type DeveloperApp = typeof developerApps.$inferSelect & { + domains: (typeof developerAppDomains.$inferSelect)[]; + apiKeys: DeveloperApiKey[]; + creditAccount: typeof developerCreditAccounts.$inferSelect | null; + videoCount: number; +}; + +export type DeveloperTransaction = + typeof developerCreditTransactions.$inferSelect; + +export async function getDeveloperApps( + user: typeof userSelectProps, +): Promise { + const apps = await db() + .select() + .from(developerApps) + .where( + and(eq(developerApps.ownerId, user.id), isNull(developerApps.deletedAt)), + ) + .orderBy(desc(developerApps.createdAt)); + + if (apps.length === 0) return []; + + const appIds = apps.map((a) => a.id); + + const [allDomains, allApiKeys, allCreditAccounts, allVideoCounts] = + await Promise.all([ + db() + .select() + .from(developerAppDomains) + .where(inArray(developerAppDomains.appId, appIds)), + db() + .select({ + id: developerApiKeys.id, + appId: developerApiKeys.appId, + keyType: developerApiKeys.keyType, + keyPrefix: developerApiKeys.keyPrefix, + encryptedKey: developerApiKeys.encryptedKey, + createdAt: developerApiKeys.createdAt, + revokedAt: developerApiKeys.revokedAt, + }) + .from(developerApiKeys) + .where( + and( + inArray(developerApiKeys.appId, appIds), + isNull(developerApiKeys.revokedAt), + ), + ), + db() + .select() + .from(developerCreditAccounts) + .where(inArray(developerCreditAccounts.appId, appIds)), + db() + .select({ + appId: developerVideos.appId, + count: count(), + }) + .from(developerVideos) + .where( + and( + inArray(developerVideos.appId, appIds), + isNull(developerVideos.deletedAt), + ), + ) + .groupBy(developerVideos.appId), + ]); + + const decryptedPublicKeys = new Map(); + for (const k of allApiKeys) { + if (k.keyType === "public" && k.encryptedKey) { + try { + decryptedPublicKeys.set(k.id, await decrypt(k.encryptedKey)); + } catch { + decryptedPublicKeys.set(k.id, `${k.keyPrefix}...`); + } + } + } + + const domainsByApp = new Map(); + for (const d of allDomains) { + const list = domainsByApp.get(d.appId) ?? []; + list.push(d); + domainsByApp.set(d.appId, list); + } + + const keysByApp = new Map(); + for (const k of allApiKeys) { + const list = keysByApp.get(k.appId) ?? []; + list.push(k); + keysByApp.set(k.appId, list); + } + + const accountsByApp = new Map(allCreditAccounts.map((c) => [c.appId, c])); + const countsByApp = new Map(allVideoCounts.map((v) => [v.appId, v.count])); + + return apps.map((app) => ({ + ...app, + domains: domainsByApp.get(app.id) ?? [], + apiKeys: (keysByApp.get(app.id) ?? []).map((k) => ({ + id: k.id, + keyType: k.keyType, + keyPrefix: k.keyPrefix, + createdAt: k.createdAt, + revokedAt: k.revokedAt, + fullKey: + k.keyType === "public" + ? (decryptedPublicKeys.get(k.id) ?? `${k.keyPrefix}...`) + : undefined, + })), + creditAccount: accountsByApp.get(app.id) ?? null, + videoCount: countsByApp.get(app.id) ?? 0, + })); +} + +export async function getDeveloperAppVideos( + appId: string, + options?: { userId?: string; limit?: number; offset?: number }, +) { + const conditions = [ + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ]; + + if (options?.userId) { + conditions.push(eq(developerVideos.externalUserId, options.userId)); + } + + return db() + .select() + .from(developerVideos) + .where(and(...conditions)) + .orderBy(desc(developerVideos.createdAt)) + .limit(options?.limit ?? 50) + .offset(options?.offset ?? 0); +} + +export async function getDeveloperTransactions(accountId: string, limit = 50) { + return db() + .select() + .from(developerCreditTransactions) + .where(eq(developerCreditTransactions.accountId, accountId)) + .orderBy(desc(developerCreditTransactions.createdAt)) + .limit(limit); +} + +export async function getDeveloperUsageSummary(appId: string) { + const [videoStats] = await db() + .select({ + totalVideos: count(), + totalDurationMinutes: sql`COALESCE(SUM(${developerVideos.duration}) / 60, 0)`, + }) + .from(developerVideos) + .where( + and(eq(developerVideos.appId, appId), isNull(developerVideos.deletedAt)), + ); + + return { + totalVideos: videoStats?.totalVideos ?? 0, + totalDurationMinutes: videoStats?.totalDurationMinutes ?? 0, + }; +} diff --git a/apps/web/app/(org)/dashboard/developers/layout.tsx b/apps/web/app/(org)/dashboard/developers/layout.tsx new file mode 100644 index 0000000000..84a4a10b81 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/layout.tsx @@ -0,0 +1,31 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { DeveloperSidebarRegistrar } from "./_components/DeveloperSidebarRegistrar"; +import { DeveloperThemeForcer } from "./_components/DeveloperThemeForcer"; +import { DevelopersProvider } from "./DevelopersContext"; +import { getDeveloperApps } from "./developer-data"; + +export const metadata: Metadata = { + title: "Developers — Cap", +}; + +export default async function DevelopersLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getCurrentUser(); + if (!user) redirect("/auth/signin"); + + const apps = await getDeveloperApps(user); + + return ( + + + + {children} + + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/page.tsx b/apps/web/app/(org)/dashboard/developers/page.tsx new file mode 100644 index 0000000000..7a92dc8f06 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function DevelopersPage() { + redirect("/dashboard/developers/apps"); +} diff --git a/apps/web/app/(org)/dashboard/developers/usage/UsageClient.tsx b/apps/web/app/(org)/dashboard/developers/usage/UsageClient.tsx new file mode 100644 index 0000000000..7b02699386 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/usage/UsageClient.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Card, CardHeader, CardTitle } from "@cap/ui"; +import { EnvironmentBadge } from "../_components/EnvironmentBadge"; +import { StatBox } from "../_components/StatBox"; +import { useDevelopersContext } from "../DevelopersContext"; + +export function UsageClient() { + const { apps } = useDevelopersContext(); + + const totalVideos = apps.reduce((sum, app) => sum + app.videoCount, 0); + const totalBalance = apps.reduce( + (sum, app) => sum + (app.creditAccount?.balanceMicroCredits ?? 0), + 0, + ); + + return ( +
+

Usage Overview

+ +
+ + + +
+ + {apps.length > 0 && ( + + + Usage by App + +
+ + + + + + + + + + + {apps.map((app) => ( + + + + + + + ))} + +
+ App + + Environment + + Videos + + Balance +
{app.name} + + + {app.videoCount} + + $ + {( + (app.creditAccount?.balanceMicroCredits ?? 0) / 100_000 + ).toFixed(2)} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/usage/page.tsx b/apps/web/app/(org)/dashboard/developers/usage/page.tsx new file mode 100644 index 0000000000..3bb1b66940 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/usage/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { UsageClient } from "./UsageClient"; + +export const metadata: Metadata = { + title: "Developer Usage — Cap", +}; + +export default async function UsagePage() { + return ; +} diff --git a/apps/web/app/(site)/docs/[...slug]/page.tsx b/apps/web/app/(site)/docs/[...slug]/page.tsx deleted file mode 100644 index 157589f8f9..0000000000 --- a/apps/web/app/(site)/docs/[...slug]/page.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { buildEnv } from "@cap/env"; -import type { Metadata } from "next"; -import Image from "next/image"; -import Link from "next/link"; -import { notFound } from "next/navigation"; -import { CustomMDX } from "@/components/mdx"; -import type { DocMetadata } from "@/utils/blog"; -import { getDocs } from "@/utils/blog"; - -type Doc = { - metadata: DocMetadata; - slug: string; - content: string; -}; - -interface DocProps { - params: Promise<{ - slug: string[]; - }>; -} - -export async function generateMetadata( - props: DocProps, -): Promise { - const params = await props.params; - if (!params?.slug) return; - - const fullSlug = params.slug.join("/"); - - // If it's a category page - if (params.slug.length === 1) { - const category = params.slug[0]; - if (!category) return; - - const displayCategory = category - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - - return { - title: `${displayCategory} Documentation - Cap`, - description: `Documentation for ${displayCategory} in Cap`, - }; - } - - // If it's a doc page - const allDocs = getDocs() as Doc[]; - const doc = allDocs.find((doc) => doc.slug === fullSlug); - if (!doc) return; - - const { title, summary, image } = doc.metadata; - const ogImage = image ? `${buildEnv.NEXT_PUBLIC_WEB_URL}${image}` : undefined; - const description = summary || title; - - return { - title, - description, - openGraph: { - title, - description, - type: "article", - url: `${buildEnv.NEXT_PUBLIC_WEB_URL}/docs/${fullSlug}`, - ...(ogImage && { - images: [{ url: ogImage }], - }), - }, - twitter: { - card: "summary_large_image", - title, - description, - ...(ogImage && { images: [ogImage] }), - }, - }; -} - -export default async function DocPage(props: DocProps) { - const params = await props.params; - if (!params?.slug) notFound(); - - const fullSlug = params.slug.join("/"); - const allDocs = getDocs() as Doc[]; - - // Handle category pages (e.g., /docs/s3-config) - if (params.slug.length === 1) { - const category = params.slug[0]; - if (!category) notFound(); - - // Find docs that either: - // 1. Have a slug that exactly matches the category, or - // 2. Have a slug that starts with category/ - const categoryDocs = allDocs - .filter( - (doc) => doc.slug === category || doc.slug.startsWith(`${category}/`), - ) - .sort((a, b) => { - // Sort by depth (root level first) - const aDepth = a.slug.split("/").length; - const bDepth = b.slug.split("/").length; - if (aDepth !== bDepth) return aDepth - bDepth; - - // Then by title - return a.metadata.title.localeCompare(b.metadata.title); - }); - - if (categoryDocs.length === 0) { - notFound(); - } - - // Format the category name for display - const displayCategory = category - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - - // Find the root category doc if it exists - const rootDoc = categoryDocs.find((doc) => doc.slug === category); - - return ( -
-

{displayCategory} Documentation

- {/* Show root category content if it exists */} - {rootDoc && ( -
- -
-
- )} - {/* Show subcategory docs */} - {categoryDocs.length > (rootDoc ? 1 : 0) && ( - <> -

Available Guides

-
- {categoryDocs - .filter((doc) => doc.slug !== category) - .map((doc) => ( - -
-

{doc.metadata.title}

- {doc.metadata.summary && ( -

- {doc.metadata.summary} -

- )} - {doc.metadata.tags && ( -
- {doc.metadata.tags.split(", ").map((tag) => ( - - {tag} - - ))} -
- )} -
- - ))} -
- - )} -
- ); - } - - // Handle individual doc pages - const doc = allDocs.find((doc) => doc.slug === fullSlug); - - if (!doc) { - notFound(); - } - - return ( -
- {doc.metadata.image && ( -
- {doc.metadata.title} -
- )} -
-
-

{doc.metadata.title}

-
-
- -
-
- ); -} diff --git a/apps/web/app/(site)/docs/[slug]/page.tsx b/apps/web/app/(site)/docs/[slug]/page.tsx deleted file mode 100644 index 31575d0d29..0000000000 --- a/apps/web/app/(site)/docs/[slug]/page.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { buildEnv } from "@cap/env"; -import type { Metadata } from "next"; -import Image from "next/image"; -import { notFound } from "next/navigation"; -import { CustomMDX } from "@/components/mdx"; -import { getDocs } from "@/utils/blog"; - -interface DocProps { - params: Promise<{ - slug: string; - }>; -} - -export async function generateMetadata( - props: DocProps, -): Promise { - const params = await props.params; - const doc = getDocs().find((doc) => doc.slug === params.slug); - if (!doc) { - return; - } - - const { title, summary: description, image } = doc.metadata; - const ogImage = image ? `${buildEnv.NEXT_PUBLIC_WEB_URL}${image}` : undefined; - - return { - title, - description, - openGraph: { - title, - description, - type: "article", - url: `${buildEnv.NEXT_PUBLIC_WEB_URL}/docs/${doc.slug}`, - ...(ogImage && { - images: [ - { - url: ogImage, - }, - ], - }), - }, - twitter: { - card: "summary_large_image", - title, - description, - ...(ogImage && { images: [ogImage] }), - }, - }; -} - -export default async function DocPage(props: DocProps) { - const params = await props.params; - const doc = getDocs().find((doc) => doc.slug === params.slug); - - if (!doc) { - notFound(); - } - - return ( -
- {doc.metadata.image && ( -
- {doc.metadata.title} -
- )} - -
-
-

{doc.metadata.title}

-
-
- -
-
- ); -} diff --git a/apps/web/app/(site)/docs/_components/DocPage.tsx b/apps/web/app/(site)/docs/_components/DocPage.tsx deleted file mode 100644 index 442d83df0a..0000000000 --- a/apps/web/app/(site)/docs/_components/DocPage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import Image from "next/image"; -import { MDXRemote } from "next-mdx-remote/rsc"; -import { getDocs } from "@/utils/blog"; - -export const DocPage = ({ docSlug }: { docSlug: string }) => { - const doc = getDocs().find((doc) => doc.slug === docSlug); - - if (!doc) { - return null; - } - - return ( -
- {doc.metadata.image && ( -
- {doc.metadata.title} -
- )} - -
-

{doc.metadata.title}

-
-
- -
- ); -}; diff --git a/apps/web/app/(site)/docs/page.tsx b/apps/web/app/(site)/docs/page.tsx deleted file mode 100644 index 252666b8fa..0000000000 --- a/apps/web/app/(site)/docs/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Metadata } from "next"; -import { DocsPage } from "@/components/pages/DocsPage"; - -export const metadata: Metadata = { - title: "Documentation — Cap", -}; - -export default function App() { - return ; -} diff --git a/apps/web/app/api/cron/developer-storage/route.ts b/apps/web/app/api/cron/developer-storage/route.ts new file mode 100644 index 0000000000..801ce5c7e8 --- /dev/null +++ b/apps/web/app/api/cron/developer-storage/route.ts @@ -0,0 +1,210 @@ +import { timingSafeEqual } from "node:crypto"; +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + developerApps, + developerCreditAccounts, + developerCreditTransactions, + developerDailyStorageSnapshots, + developerVideos, +} from "@cap/database/schema"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +const MICRO_CREDITS_PER_MINUTE_PER_DAY_NUMERATOR = 333; +const MICRO_CREDITS_PER_MINUTE_PER_DAY_DENOMINATOR = 100; + +export async function GET(request: Request) { + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + + const authHeader = request.headers.get("authorization"); + const expected = `Bearer ${cronSecret}`; + if ( + !authHeader || + authHeader.length !== expected.length || + !timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected)) + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const today = new Date().toISOString().slice(0, 10); + + const apps = await db() + .select({ id: developerApps.id }) + .from(developerApps) + .where(isNull(developerApps.deletedAt)); + + if (apps.length === 0) { + return NextResponse.json({ + success: true, + date: today, + appsProcessed: 0, + }); + } + + const appIds = apps.map((a) => a.id); + + const existingSnapshots = await db() + .select() + .from(developerDailyStorageSnapshots) + .where( + and( + inArray(developerDailyStorageSnapshots.appId, appIds), + eq(developerDailyStorageSnapshots.snapshotDate, today), + ), + ); + + const snapshotsByApp = new Map(existingSnapshots.map((s) => [s.appId, s])); + + const videoStats = await db() + .select({ + appId: developerVideos.appId, + totalDurationMinutes: sql`COALESCE(SUM(${developerVideos.duration}) / 60, 0)`, + videoCount: sql`COUNT(*)`, + }) + .from(developerVideos) + .where( + and( + inArray(developerVideos.appId, appIds), + isNull(developerVideos.deletedAt), + ), + ) + .groupBy(developerVideos.appId); + + const statsByApp = new Map(videoStats.map((s) => [s.appId, s])); + + const accounts = await db() + .select() + .from(developerCreditAccounts) + .where(inArray(developerCreditAccounts.appId, appIds)); + + const accountsByApp = new Map(accounts.map((a) => [a.appId, a])); + + const appsToProcess = apps.filter((app) => { + const existing = snapshotsByApp.get(app.id); + if (existing?.processedAt) return false; + + const stats = statsByApp.get(app.id); + const totalMinutes = stats?.totalDurationMinutes ?? 0; + if (totalMinutes <= 0) return false; + + const microCreditsToCharge = Math.floor( + (totalMinutes * MICRO_CREDITS_PER_MINUTE_PER_DAY_NUMERATOR) / + MICRO_CREDITS_PER_MINUTE_PER_DAY_DENOMINATOR, + ); + if (microCreditsToCharge <= 0) return false; + + const account = accountsByApp.get(app.id); + return !!account; + }); + + const BATCH_SIZE = 10; + let processed = 0; + + for (let i = 0; i < appsToProcess.length; i += BATCH_SIZE) { + const batch = appsToProcess.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map(async (app) => { + const existing = snapshotsByApp.get(app.id); + const stats = statsByApp.get(app.id); + const totalMinutes = stats?.totalDurationMinutes ?? 0; + const videoCount = Number(stats?.videoCount ?? 0); + const microCreditsToCharge = Math.floor( + (totalMinutes * MICRO_CREDITS_PER_MINUTE_PER_DAY_NUMERATOR) / + MICRO_CREDITS_PER_MINUTE_PER_DAY_DENOMINATOR, + ); + const account = accountsByApp.get(app.id); + if (!account) return false; + + await db().transaction(async (tx) => { + const [result] = await tx + .update(developerCreditAccounts) + .set({ + balanceMicroCredits: sql`${developerCreditAccounts.balanceMicroCredits} - ${microCreditsToCharge}`, + }) + .where( + and( + eq(developerCreditAccounts.id, account.id), + sql`${developerCreditAccounts.balanceMicroCredits} >= ${microCreditsToCharge}`, + ), + ); + + const affectedRows = + (result as unknown as { affectedRows?: number })?.affectedRows ?? 0; + if (affectedRows === 0) { + return; + } + + const [updated] = await tx + .select({ + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.id, account.id)) + .limit(1); + + if (!updated) return; + + await tx.insert(developerCreditTransactions).values({ + id: nanoId(), + accountId: account.id, + type: "storage_daily", + amountMicroCredits: -microCreditsToCharge, + balanceAfterMicroCredits: updated.balanceMicroCredits, + referenceType: "manual", + metadata: { + snapshotDate: today, + totalDurationMinutes: totalMinutes, + videoCount, + }, + }); + + const snapshotId = existing?.id ?? nanoId(); + if (existing) { + await tx + .update(developerDailyStorageSnapshots) + .set({ + totalDurationMinutes: totalMinutes, + videoCount, + microCreditsCharged: microCreditsToCharge, + processedAt: new Date(), + }) + .where(eq(developerDailyStorageSnapshots.id, snapshotId)); + } else { + await tx.insert(developerDailyStorageSnapshots).values({ + id: snapshotId, + appId: app.id, + snapshotDate: today, + totalDurationMinutes: totalMinutes, + videoCount, + microCreditsCharged: microCreditsToCharge, + processedAt: new Date(), + }); + } + }); + + return true; + }), + ); + + for (const result of results) { + if (result.status === "fulfilled" && result.value === true) { + processed++; + } else if (result.status === "rejected") { + console.error("Failed to process app in cron:", result.reason); + } + } + } + + return NextResponse.json({ + success: true, + date: today, + appsProcessed: processed, + }); +} diff --git a/apps/web/app/api/developer/credits/checkout/route.ts b/apps/web/app/api/developer/credits/checkout/route.ts new file mode 100644 index 0000000000..112b026f7c --- /dev/null +++ b/apps/web/app/api/developer/credits/checkout/route.ts @@ -0,0 +1,141 @@ +import { db } from "@cap/database"; +import { + developerApps, + developerCreditAccounts, + users, +} from "@cap/database/schema"; +import { buildEnv, serverEnv } from "@cap/env"; +import { STRIPE_DEVELOPER_CREDITS_PRODUCT_ID, stripe } from "@cap/utils"; +import { zValidator } from "@hono/zod-validator"; +import { and, eq, isNull } from "drizzle-orm"; +import { Hono } from "hono"; +import { handle } from "hono/vercel"; +import type Stripe from "stripe"; +import { z } from "zod"; +import { corsMiddleware, withAuth } from "../../../utils"; + +const app = new Hono() + .basePath("/api/developer/credits/checkout") + .use(corsMiddleware) + .use(withAuth); + +app.post( + "/", + zValidator( + "json", + z.object({ + appId: z.string(), + amountCents: z.number().int().min(500).max(100_000), + }), + ), + async (c) => { + const user = c.get("user"); + const { appId, amountCents } = c.req.valid("json"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) { + return c.json({ error: "App not found" }, 404); + } + + const [account] = await db() + .select() + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .limit(1); + + if (!account) { + return c.json({ error: "Credit account not found" }, 404); + } + + try { + let customerId = account.stripeCustomerId ?? user.stripeCustomerId; + + if (!customerId) { + const existingCustomers = await stripe().customers.list({ + email: user.email, + limit: 1, + }); + + let customer: Stripe.Customer; + if (existingCustomers.data.length > 0 && existingCustomers.data[0]) { + customer = existingCustomers.data[0]; + customer = await stripe().customers.update(customer.id, { + metadata: { + ...customer.metadata, + userId: user.id, + }, + }); + } else { + customer = await stripe().customers.create({ + email: user.email, + metadata: { + userId: user.id, + }, + }); + } + + await db() + .update(users) + .set({ stripeCustomerId: customer.id }) + .where(eq(users.id, user.id)); + + await db() + .update(developerCreditAccounts) + .set({ stripeCustomerId: customer.id }) + .where(eq(developerCreditAccounts.id, account.id)); + + customerId = customer.id; + } + + const checkoutSession = await stripe().checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price_data: { + currency: "usd", + product: + STRIPE_DEVELOPER_CREDITS_PRODUCT_ID[ + buildEnv.NEXT_PUBLIC_IS_CAP ? "production" : "development" + ], + unit_amount: amountCents, + }, + quantity: 1, + }, + ], + mode: "payment", + success_url: `${serverEnv().WEB_URL}/dashboard/developers/credits?purchase=success`, + cancel_url: `${serverEnv().WEB_URL}/dashboard/developers/credits`, + metadata: { + type: "developer_credits", + appId, + accountId: account.id, + amountCents: String(amountCents), + userId: user.id, + }, + }); + + if (checkoutSession.url) { + return c.json({ url: checkoutSession.url }); + } + + return c.json({ error: "Failed to create checkout session" }, 500); + } catch (error) { + console.error("Error creating developer credits checkout:", error); + return c.json({ error: "Failed to create checkout session" }, 500); + } + }, +); + +export const POST = handle(app); +export const OPTIONS = handle(app); diff --git a/apps/web/app/api/developer/sdk/v1/[...route]/route.ts b/apps/web/app/api/developer/sdk/v1/[...route]/route.ts new file mode 100644 index 0000000000..bb49848fc9 --- /dev/null +++ b/apps/web/app/api/developer/sdk/v1/[...route]/route.ts @@ -0,0 +1,16 @@ +import { Hono } from "hono"; +import { handle } from "hono/vercel"; +import { developerRateLimiter, developerSdkCors } from "../../../../utils"; +import * as upload from "./upload"; +import * as videoCreate from "./video-create"; + +const app = new Hono() + .basePath("/api/developer/sdk/v1") + .use(developerSdkCors) + .use(developerRateLimiter) + .route("/videos", videoCreate.app) + .route("/upload/multipart", upload.app); + +export const GET = handle(app); +export const POST = handle(app); +export const OPTIONS = handle(app); diff --git a/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts b/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts new file mode 100644 index 0000000000..ef8017944b --- /dev/null +++ b/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts @@ -0,0 +1,351 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + developerCreditAccounts, + developerCreditTransactions, + developerVideos, +} from "@cap/database/schema"; +import { provideOptionalAuth, S3Buckets } from "@cap/web-backend"; +import { zValidator } from "@hono/zod-validator"; +import { and, eq, sql } from "drizzle-orm"; +import { Effect } from "effect"; +import { Hono } from "hono"; +import { z } from "zod"; +import { runPromise } from "@/lib/server"; +import { withDeveloperPublicAuth } from "../../../../utils"; + +const MICRO_CREDITS_PER_MINUTE = 5_000; +const MAX_DURATION_SECS = 4 * 60 * 60; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "public"; + }; +}>().use(withDeveloperPublicAuth); + +app.post( + "/initiate", + zValidator( + "json", + z.object({ + videoId: z.string(), + contentType: z.string().optional(), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const { videoId, contentType } = c.req.valid("json"); + + const [video] = await db() + .select({ + id: developerVideos.id, + appId: developerVideos.appId, + s3Key: developerVideos.s3Key, + }) + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + + if (!video || video.appId !== appId) { + return c.json({ error: "Video not found" }, 404); + } + + if (!video.s3Key) { + return c.json({ error: "Video has no S3 key" }, 400); + } + + const s3Key = video.s3Key; + + const ALLOWED_CONTENT_TYPES = [ + "video/mp4", + "video/webm", + "video/quicktime", + "video/x-matroska", + "video/avi", + "application/octet-stream", + ]; + + const resolvedContentType = + contentType && ALLOWED_CONTENT_TYPES.includes(contentType) + ? contentType + : "video/mp4"; + + try { + const uploadId = await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(); + const { UploadId } = yield* bucket.multipart.create(s3Key, { + ContentType: resolvedContentType, + CacheControl: "max-age=31536000", + }); + if (!UploadId) throw new Error("No UploadId returned"); + return UploadId; + }).pipe(provideOptionalAuth, runPromise); + + return c.json({ uploadId }); + } catch (error) { + console.error("Error initiating multipart upload:", error); + return c.json({ error: "Failed to initiate upload" }, 500); + } + }, +); + +app.post( + "/presign-part", + zValidator( + "json", + z.object({ + videoId: z.string(), + uploadId: z.string(), + partNumber: z.number().int().min(1).max(10000), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const { videoId, uploadId, partNumber } = c.req.valid("json"); + + const [video] = await db() + .select({ + id: developerVideos.id, + appId: developerVideos.appId, + s3Key: developerVideos.s3Key, + }) + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + + if (!video || video.appId !== appId) { + return c.json({ error: "Video not found" }, 404); + } + + if (!video.s3Key) { + return c.json({ error: "Video has no S3 key" }, 400); + } + + const s3Key = video.s3Key; + + try { + const presignedUrl = await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(); + return yield* bucket.multipart.getPresignedUploadPartUrl( + s3Key, + uploadId, + partNumber, + ); + }).pipe(provideOptionalAuth, runPromise); + + return c.json({ presignedUrl }); + } catch (error) { + console.error("Error creating presigned URL:", error); + return c.json({ error: "Failed to create presigned URL" }, 500); + } + }, +); + +app.post( + "/complete", + zValidator( + "json", + z.object({ + videoId: z.string(), + uploadId: z.string(), + parts: z.array( + z.object({ + partNumber: z.number().int().min(1).max(10000), + etag: z.string(), + size: z.number().nonnegative(), + }), + ), + durationInSecs: z.number().positive(), + width: z.number().optional(), + height: z.number().optional(), + fps: z.number().optional(), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const { videoId, uploadId, parts, durationInSecs, width, height, fps } = + c.req.valid("json"); + + const clampedDuration = Math.min(durationInSecs, MAX_DURATION_SECS); + + const [video] = await db() + .select({ + id: developerVideos.id, + appId: developerVideos.appId, + s3Key: developerVideos.s3Key, + }) + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + + if (!video || video.appId !== appId) { + return c.json({ error: "Video not found" }, 404); + } + + if (!video.s3Key) { + return c.json({ error: "Video has no S3 key" }, 400); + } + + const s3Key = video.s3Key; + + try { + const totalBytes = parts.reduce((sum, p) => sum + p.size, 0); + const sizeBasedMinDuration = totalBytes / 2_500_000; + const billingDuration = Math.min( + Math.max(clampedDuration, sizeBasedMinDuration), + MAX_DURATION_SECS, + ); + const durationMinutes = billingDuration / 60; + const microCreditsToDebit = Math.floor( + durationMinutes * MICRO_CREDITS_PER_MINUTE, + ); + + if (microCreditsToDebit > 0) { + const debited = await db().transaction(async (tx) => { + const [account] = await tx + .select({ + id: developerCreditAccounts.id, + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .limit(1); + + if (!account) return null; + + const [result] = await tx + .update(developerCreditAccounts) + .set({ + balanceMicroCredits: sql`${developerCreditAccounts.balanceMicroCredits} - ${microCreditsToDebit}`, + }) + .where( + and( + eq(developerCreditAccounts.id, account.id), + sql`${developerCreditAccounts.balanceMicroCredits} >= ${microCreditsToDebit}`, + ), + ); + + const affectedRows = + (result as unknown as { affectedRows?: number })?.affectedRows ?? 0; + if (affectedRows === 0) { + return false; + } + + const [updated] = await tx + .select({ + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.id, account.id)) + .limit(1); + + if (!updated) return false; + + await tx.insert(developerCreditTransactions).values({ + id: nanoId(), + accountId: account.id, + type: "video_create", + amountMicroCredits: -microCreditsToDebit, + balanceAfterMicroCredits: updated.balanceMicroCredits, + referenceId: videoId, + referenceType: "developer_video", + metadata: { + durationSeconds: billingDuration, + totalBytes, + }, + }); + + return true; + }); + + if (debited === null) { + return c.json({ error: "Credit account not found" }, 402); + } + if (!debited) { + return c.json({ error: "Insufficient credits" }, 402); + } + } + + await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(); + + const sortedParts = [...parts].sort( + (a, b) => a.partNumber - b.partNumber, + ); + const formattedParts = sortedParts.map((part) => ({ + PartNumber: part.partNumber, + ETag: part.etag, + })); + + yield* bucket.multipart.complete(s3Key, uploadId, { + MultipartUpload: { Parts: formattedParts }, + }); + }).pipe(provideOptionalAuth, runPromise); + + const updates: Partial = { + duration: clampedDuration, + }; + if (width !== undefined) updates.width = width; + if (height !== undefined) updates.height = height; + if (fps !== undefined) updates.fps = fps; + + await db() + .update(developerVideos) + .set(updates) + .where(eq(developerVideos.id, videoId)); + + return c.json({ success: true }); + } catch (error) { + console.error("Error completing multipart upload:", error); + return c.json({ error: "Failed to complete upload" }, 500); + } + }, +); + +app.post( + "/abort", + zValidator( + "json", + z.object({ + videoId: z.string(), + uploadId: z.string(), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const { videoId, uploadId } = c.req.valid("json"); + + const [video] = await db() + .select({ + id: developerVideos.id, + appId: developerVideos.appId, + s3Key: developerVideos.s3Key, + }) + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + + if (!video || video.appId !== appId) { + return c.json({ error: "Video not found" }, 404); + } + + if (!video.s3Key) { + return c.json({ error: "Video has no S3 key" }, 400); + } + + const s3Key = video.s3Key; + + try { + await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(); + yield* bucket.multipart.abort(s3Key, uploadId); + }).pipe(provideOptionalAuth, runPromise); + + return c.json({ success: true }); + } catch (error) { + console.error("Error aborting multipart upload:", error); + return c.json({ error: "Failed to abort upload" }, 500); + } + }, +); diff --git a/apps/web/app/api/developer/sdk/v1/[...route]/video-create.ts b/apps/web/app/api/developer/sdk/v1/[...route]/video-create.ts new file mode 100644 index 0000000000..ff89dbf72c --- /dev/null +++ b/apps/web/app/api/developer/sdk/v1/[...route]/video-create.ts @@ -0,0 +1,75 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { developerCreditAccounts, developerVideos } from "@cap/database/schema"; +import { buildEnv } from "@cap/env"; +import { zValidator } from "@hono/zod-validator"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import { withDeveloperPublicAuth } from "../../../../utils"; + +const MIN_BALANCE_MICRO_CREDITS = 5_000; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "public"; + }; +}>().use(withDeveloperPublicAuth); + +app.post( + "/create", + zValidator( + "json", + z.object({ + name: z.string().max(255).optional(), + userId: z.string().max(255).optional(), + metadata: z + .record(z.unknown()) + .optional() + .refine( + (val) => val === undefined || JSON.stringify(val).length <= 8192, + { message: "Metadata must be under 8KB" }, + ), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const body = c.req.valid("json"); + + const [account] = await db() + .select({ + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .limit(1); + + if (!account || account.balanceMicroCredits < MIN_BALANCE_MICRO_CREDITS) { + return c.json({ error: "Insufficient credits" }, 402); + } + + const videoId = nanoId(); + const s3Key = `developer/${appId}/${videoId}/video`; + + await db() + .insert(developerVideos) + .values({ + id: videoId, + appId, + externalUserId: body.userId, + name: body.name ?? "Untitled", + s3Key, + metadata: body.metadata, + }); + + const webUrl = buildEnv.NEXT_PUBLIC_WEB_URL; + + return c.json({ + videoId, + s3Key, + shareUrl: `${webUrl}/dev/${videoId}`, + embedUrl: `${webUrl}/embed/${videoId}?sdk=1`, + }); + }, +); diff --git a/apps/web/app/api/developer/v1/[...route]/route.ts b/apps/web/app/api/developer/v1/[...route]/route.ts new file mode 100644 index 0000000000..509daa2753 --- /dev/null +++ b/apps/web/app/api/developer/v1/[...route]/route.ts @@ -0,0 +1,18 @@ +import { Hono } from "hono"; +import { handle } from "hono/vercel"; +import { developerRateLimiter, developerSdkCors } from "../../../utils"; + +import * as usage from "./usage"; +import * as videos from "./videos"; + +const app = new Hono() + .basePath("/api/developer/v1") + .use(developerSdkCors) + .use(developerRateLimiter) + .route("/videos", videos.app) + .route("/usage", usage.app); + +export const GET = handle(app); +export const POST = handle(app); +export const DELETE = handle(app); +export const OPTIONS = handle(app); diff --git a/apps/web/app/api/developer/v1/[...route]/usage.ts b/apps/web/app/api/developer/v1/[...route]/usage.ts new file mode 100644 index 0000000000..d478586986 --- /dev/null +++ b/apps/web/app/api/developer/v1/[...route]/usage.ts @@ -0,0 +1,47 @@ +import { db } from "@cap/database"; +import { developerCreditAccounts, developerVideos } from "@cap/database/schema"; +import { and, count, eq, isNull, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { withDeveloperSecretAuth } from "../../../utils"; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "secret"; + }; +}>().use(withDeveloperSecretAuth); + +app.get("/", async (c) => { + const appId = c.get("developerAppId"); + + const [[account], [videoStats]] = await Promise.all([ + db() + .select() + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .limit(1), + db() + .select({ + totalVideos: count(), + totalDurationMinutes: sql`COALESCE(SUM(${developerVideos.duration}) / 60, 0)`, + }) + .from(developerVideos) + .where( + and( + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), + ), + ]); + + return c.json({ + data: { + balanceMicroCredits: account?.balanceMicroCredits ?? 0, + balanceDollars: ((account?.balanceMicroCredits ?? 0) / 100_000).toFixed( + 2, + ), + totalVideos: videoStats?.totalVideos ?? 0, + totalDurationMinutes: videoStats?.totalDurationMinutes ?? 0, + }, + }); +}); diff --git a/apps/web/app/api/developer/v1/[...route]/videos.ts b/apps/web/app/api/developer/v1/[...route]/videos.ts new file mode 100644 index 0000000000..e8cbecdb1e --- /dev/null +++ b/apps/web/app/api/developer/v1/[...route]/videos.ts @@ -0,0 +1,125 @@ +import { db } from "@cap/database"; +import { developerVideos } from "@cap/database/schema"; +import { and, desc, eq, isNull } from "drizzle-orm"; +import { Hono } from "hono"; +import { withDeveloperSecretAuth } from "../../../utils"; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "secret"; + }; +}>().use(withDeveloperSecretAuth); + +app.get("/", async (c) => { + const appId = c.get("developerAppId"); + const userId = c.req.query("userId"); + const limit = Math.min(Number(c.req.query("limit") ?? 50) || 50, 100); + const offset = Math.max(0, Number(c.req.query("offset") ?? 0) || 0); + + const conditions = [ + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ]; + + if (userId) { + conditions.push(eq(developerVideos.externalUserId, userId)); + } + + const videos = await db() + .select() + .from(developerVideos) + .where(and(...conditions)) + .orderBy(desc(developerVideos.createdAt)) + .limit(limit) + .offset(offset); + + return c.json({ data: videos }); +}); + +app.get("/:id", async (c) => { + const appId = c.get("developerAppId"); + const videoId = c.req.param("id"); + + const [video] = await db() + .select() + .from(developerVideos) + .where( + and( + eq(developerVideos.id, videoId), + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), + ) + .limit(1); + + if (!video) { + return c.json({ error: "Video not found" }, 404); + } + + return c.json({ data: video }); +}); + +app.delete("/:id", async (c) => { + const appId = c.get("developerAppId"); + const videoId = c.req.param("id"); + + const [video] = await db() + .select() + .from(developerVideos) + .where( + and( + eq(developerVideos.id, videoId), + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), + ) + .limit(1); + + if (!video) { + return c.json({ error: "Video not found" }, 404); + } + + await db() + .update(developerVideos) + .set({ deletedAt: new Date() }) + .where(eq(developerVideos.id, videoId)); + + return c.json({ success: true }); +}); + +app.get("/:id/status", async (c) => { + const appId = c.get("developerAppId"); + const videoId = c.req.param("id"); + + const [video] = await db() + .select({ + id: developerVideos.id, + duration: developerVideos.duration, + width: developerVideos.width, + height: developerVideos.height, + transcriptionStatus: developerVideos.transcriptionStatus, + }) + .from(developerVideos) + .where( + and( + eq(developerVideos.id, videoId), + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), + ) + .limit(1); + + if (!video) { + return c.json({ error: "Video not found" }, 404); + } + + const ready = video.duration !== null && video.width !== null; + + return c.json({ + data: { + ...video, + ready, + }, + }); +}); diff --git a/apps/web/app/api/utils.ts b/apps/web/app/api/utils.ts index aeab0dd0a3..1feeb4db91 100644 --- a/apps/web/app/api/utils.ts +++ b/apps/web/app/api/utils.ts @@ -1,21 +1,78 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { authApiKeys, users } from "@cap/database/schema"; +import { + authApiKeys, + developerApiKeys, + developerAppDomains, + developerApps, + users, +} from "@cap/database/schema"; import { buildEnv } from "@cap/env"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import type { Context } from "hono"; import { cors } from "hono/cors"; import { createMiddleware } from "hono/factory"; -import { cookies } from "next/headers"; +import { hashKey } from "@/lib/developer-key-hash"; + +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX_REQUESTS = 60; +const RATE_LIMIT_MAX_ENTRIES = 10_000; + +const requestCounts = new Map(); +let rateLimitRequestCounter = 0; + +export const developerRateLimiter = createMiddleware(async (c, next) => { + const key = + c.req.header("authorization") ?? + c.req.header("x-forwarded-for") ?? + "unknown"; + const now = Date.now(); + + rateLimitRequestCounter++; + if (rateLimitRequestCounter % 100 === 0) { + for (const [k, v] of requestCounts) { + if (now > v.resetAt) requestCounts.delete(k); + } + if (requestCounts.size > RATE_LIMIT_MAX_ENTRIES) { + requestCounts.clear(); + } + } + + const entry = requestCounts.get(key); + + if (!entry || now > entry.resetAt) { + requestCounts.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); + } else { + entry.count++; + if (entry.count > RATE_LIMIT_MAX_REQUESTS) { + return c.json({ error: "Rate limit exceeded" }, 429); + } + } + + await next(); +}); + +const LAST_USED_DEBOUNCE_MS = 5 * 60 * 1000; +const lastUsedWriteTimes = new Map(); + +function debouncedLastUsedUpdate(keyHash: string) { + const now = Date.now(); + const lastWrite = lastUsedWriteTimes.get(keyHash); + if (lastWrite && now - lastWrite < LAST_USED_DEBOUNCE_MS) return; + lastUsedWriteTimes.set(keyHash, now); + db() + .update(developerApiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(developerApiKeys.keyHash, keyHash)) + .catch((err) => console.error("Failed to update lastUsedAt:", err)); +} async function getAuth(c: Context) { - console.log("auth header: ", c.req.header("authorization")); const authHeader = c.req.header("authorization")?.split(" ")[1]; let user; if (authHeader?.length === 36) { - console.log("Using API key auth"); const res = await db() .select() .from(users) @@ -23,21 +80,9 @@ async function getAuth(c: Context) { .where(eq(authApiKeys.id, authHeader)); user = res[0]?.users; } else { - if (authHeader) - (await cookies()).set({ - name: "next-auth.session-token", - value: authHeader, - path: "/", - sameSite: "none", - secure: true, - httpOnly: true, - }); - user = await getCurrentUser(); } - console.log("User: ", user); - if (!user) return; return { user }; } @@ -82,3 +127,121 @@ export const corsMiddleware = cors({ allowMethods: ["POST", "OPTIONS"], allowHeaders: ["Content-Type", "Authorization", "sentry-trace", "baggage"], }); + +export const developerSdkCors = cors({ + origin: "*", + credentials: false, + allowMethods: ["GET", "POST", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization"], +}); + +export const withDeveloperPublicAuth = createMiddleware<{ + Variables: { + developerAppId: string; + developerKeyType: "public"; + }; +}>(async (c, next) => { + const authHeader = c.req.header("authorization")?.split(" ")[1]; + if (!authHeader?.startsWith("cpk_")) { + return c.json({ error: "Invalid public key" }, 401); + } + + const keyHash = await hashKey(authHeader); + const [row] = await db() + .select({ + appId: developerApps.id, + environment: developerApps.environment, + }) + .from(developerApiKeys) + .innerJoin( + developerApps, + and( + eq(developerApiKeys.appId, developerApps.id), + isNull(developerApps.deletedAt), + ), + ) + .where( + and( + eq(developerApiKeys.keyHash, keyHash), + eq(developerApiKeys.keyType, "public"), + isNull(developerApiKeys.revokedAt), + ), + ) + .limit(1); + + if (!row) { + return c.json({ error: "Invalid or revoked public key" }, 401); + } + + const origin = c.req.header("origin"); + if (row.environment === "production") { + if (!origin) { + return c.json( + { error: "Origin header required for production apps" }, + 403, + ); + } + const [allowedDomain] = await db() + .select({ id: developerAppDomains.id }) + .from(developerAppDomains) + .where( + and( + eq(developerAppDomains.appId, row.appId), + eq(developerAppDomains.domain, origin), + ), + ) + .limit(1); + + if (!allowedDomain) { + return c.json({ error: "Origin not allowed" }, 403); + } + } + + debouncedLastUsedUpdate(keyHash); + + c.set("developerAppId", row.appId); + c.set("developerKeyType", "public" as const); + await next(); +}); + +export const withDeveloperSecretAuth = createMiddleware<{ + Variables: { + developerAppId: string; + developerKeyType: "secret"; + }; +}>(async (c, next) => { + const authHeader = c.req.header("authorization")?.split(" ")[1]; + if (!authHeader?.startsWith("csk_")) { + return c.json({ error: "Invalid secret key" }, 401); + } + + const keyHash = await hashKey(authHeader); + const [row] = await db() + .select({ appId: developerApps.id }) + .from(developerApiKeys) + .innerJoin( + developerApps, + and( + eq(developerApiKeys.appId, developerApps.id), + isNull(developerApps.deletedAt), + ), + ) + .where( + and( + eq(developerApiKeys.keyHash, keyHash), + eq(developerApiKeys.keyType, "secret"), + isNull(developerApiKeys.revokedAt), + ), + ) + .limit(1); + + if (!row) { + return c.json({ error: "Invalid or revoked secret key" }, 401); + } + + debouncedLastUsedUpdate(keyHash); + + c.set("developerAppId", row.appId); + c.set("developerKeyType", "secret" as const); + await next(); +}); diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 8624da99b1..adb2a8cc21 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -1,13 +1,14 @@ import { db } from "@cap/database"; import { nanoId } from "@cap/database/helpers"; -import { users } from "@cap/database/schema"; +import { developerCreditTransactions, users } from "@cap/database/schema"; import { buildEnv, serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import { Organisation, User } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { PostHog } from "posthog-node"; import type Stripe from "stripe"; +import { addCreditsToAccount } from "@/lib/developer-credits"; const relevantEvents = new Set([ "checkout.session.completed", @@ -48,7 +49,7 @@ async function createGuestUser( async function findUserWithRetry( email: string, userId?: User.UserId, - maxRetries = 5, + maxRetries = 3, ): Promise { for (let i = 0; i < maxRetries; i++) { console.log(`[Attempt ${i + 1}/${maxRetries}] Looking for user:`, { @@ -90,7 +91,7 @@ async function findUserWithRetry( } if (i < maxRetries - 1) { - const delay = 2 ** i * 3000; + const delay = 2 ** i * 1000; console.log( `No user found on attempt ${ i + 1 @@ -101,7 +102,7 @@ async function findUserWithRetry( } catch (error) { console.error(`Error during attempt ${i + 1}:`, error); if (i < maxRetries - 1) { - const delay = 2 ** i * 3000; + const delay = 2 ** i * 1000; await new Promise((resolve) => setTimeout(resolve, delay)); } } @@ -144,6 +145,66 @@ export const POST = async (req: Request) => { subscriptionId: session.subscription, }); + if (session.metadata?.type === "developer_credits") { + const { accountId, amountCents } = session.metadata; + const paymentIntentId = + typeof session.payment_intent === "string" + ? session.payment_intent + : null; + + if (!accountId || !amountCents || !paymentIntentId) { + console.error("Missing required metadata for developer credits:", { + accountId, + amountCents, + paymentIntentId, + }); + return new Response("Missing metadata", { status: 400 }); + } + + console.log("Processing developer credits purchase:", { + accountId, + amountCents, + paymentIntentId, + }); + + const [existingTxn] = await db() + .select({ id: developerCreditTransactions.id }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.referenceId, paymentIntentId), + eq( + developerCreditTransactions.referenceType, + "stripe_payment_intent", + ), + ), + ) + .limit(1); + + if (existingTxn) { + console.log( + "Duplicate webhook delivery — transaction already exists:", + existingTxn.id, + ); + return NextResponse.json({ received: true }); + } + + await addCreditsToAccount({ + accountId, + amountCents: Number(amountCents), + referenceId: paymentIntentId, + referenceType: "stripe_payment_intent", + metadata: { + amountCents: Number(amountCents), + stripeSessionId: session.id, + }, + }); + + console.log("Developer credits added successfully"); + return NextResponse.json({ received: true }); + } + const customer = await stripe().customers.retrieve( session.customer as string, ); diff --git a/apps/web/app/dev/[videoId]/page.tsx b/apps/web/app/dev/[videoId]/page.tsx new file mode 100644 index 0000000000..4bf6103f23 --- /dev/null +++ b/apps/web/app/dev/[videoId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function DevVideoPage({ + params, +}: { + params: Promise<{ videoId: string }>; +}) { + const { videoId } = await params; + redirect(`/embed/${videoId}?sdk=1`); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 39baa1e634..20a677a014 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -783,7 +783,8 @@ footer a { } .prose .not-prose p, -.prose .not-prose li { +.prose .not-prose li, +.prose .not-prose a { color: inherit !important; font-size: inherit !important; line-height: inherit !important; diff --git a/apps/web/components/mdx.tsx b/apps/web/components/mdx.tsx index ef9918ded4..090d2f2acd 100644 --- a/apps/web/components/mdx.tsx +++ b/apps/web/components/mdx.tsx @@ -2,6 +2,9 @@ import Image from "next/image"; import Link from "next/link"; import { MDXRemote } from "next-mdx-remote/rsc"; import React, { type ReactNode } from "react"; +import type { Options as RehypePrettyCodeOptions } from "rehype-pretty-code"; +import rehypePrettyCode from "rehype-pretty-code"; +import remarkGfm from "remark-gfm"; interface TableData { headers: string[]; @@ -105,6 +108,26 @@ function Warning(props: WarningProps) { ); } +interface CapEmbedProps { + src: string; +} + +function CapEmbed({ src }: CapEmbedProps) { + return ( + - + ## Quick Start (One Command) @@ -62,6 +48,8 @@ Best for VPS, home servers, or any Docker-capable host. 2. Run `docker compose up -d` 3. Access Cap at `http://localhost:3000` +> **Note:** The main `docker-compose.yml` builds the media server from source, so a full clone of the repository is required. The Coolify compose file uses a pre-built image from `ghcr.io` instead. + **Custom configuration:** Create a `.env` file to customize your deployment: @@ -99,6 +87,8 @@ For Coolify users, use `docker-compose.coolify.yml` which includes environment v 4. Configure environment variables in Coolify's UI 5. Deploy +> **Note:** The Coolify compose file uses slightly different environment variable names: `WEB_URL` instead of `CAP_URL`, and `S3_PUBLIC_ENDPOINT` instead of `S3_PUBLIC_URL`. + ## Connecting Cap Desktop 1. Open Cap Desktop settings diff --git a/apps/web/content/docs/sharing/analytics.mdx b/apps/web/content/docs/sharing/analytics.mdx new file mode 100644 index 0000000000..04ccf5ffa0 --- /dev/null +++ b/apps/web/content/docs/sharing/analytics.mdx @@ -0,0 +1,92 @@ +--- +title: "Analytics" +summary: "Track views and engagement on your recordings" +tags: "Sharing, Analytics" +--- + +Cap provides analytics for your shared recordings so you can understand who is viewing your content and how your recordings are performing over time. Analytics are available on your Cap dashboard for every recording you have shared. + +## Accessing Analytics + +To view analytics for a recording: + +1. Open your [Cap dashboard](https://cap.so/dashboard). +2. Click on the recording you want to analyze. +3. The analytics data is displayed alongside the recording details. + +Each recording shows its engagement metrics directly on the dashboard, giving you a quick overview without needing to dig into individual pages. + +## Metrics + +### View Count + +The total number of times your recording has been watched. A view is counted each time someone opens the share link and the video starts playing. + +Key details about view tracking: + +- Each page load that initiates playback counts as a view +- Refreshing the page and watching again counts as an additional view +- Embedded views are counted the same as direct link views + +### Unique Views + +The number of distinct viewing sessions for your recording. This helps you understand your actual audience reach, separate from repeat views by the same person in a single session. + +Unique views are tracked using anonymous session identifiers. Cap does not collect personally identifiable information about viewers unless they are signed in with a Cap account. + +## Dashboard Overview + +Your Cap dashboard provides a high-level view of all your recordings with key metrics visible at a glance: + +| Column | Description | +|--------|-------------| +| Recording | Thumbnail and title of the recording | +| Views | Total view count | +| Created | When the recording was made | + +Click on any recording to see its full analytics detail page with all available metrics. + +## Using Analytics Effectively + +### Measuring Content Performance + +Compare view counts across your recordings to understand what resonates with your audience. Recordings with high view counts indicate content that your viewers find valuable. + +### Optimizing Recording Length + +Shorter recordings tend to get more complete views. Consider keeping recordings concise and front-loading the most important information to maximize engagement. + +### Tracking Share Reach + +When you share a recording in multiple places (Slack, email, documentation), the view count tells you the total reach across all channels. A spike in views after sharing in a specific channel tells you where your audience is most engaged. + +### Team Communication + +For team standups and updates shared via Cap, analytics confirm whether your teammates are actually watching. If view counts are low, consider adjusting your sharing approach or recording format. + +### Client and Stakeholder Updates + +When sharing product demos or project updates with clients, analytics tell you whether they have watched the recording. This helps you follow up appropriately, knowing whether the client has seen the latest update. + +## Frequently Asked Questions + +**Are analytics available on the free plan?** + +Basic analytics including view counts are available on all plans. Detailed analytics with unique views may require a paid plan. + +**Can I see who specifically watched my recording?** + +Cap prioritizes viewer privacy. Analytics show aggregate data like view counts and unique view counts. Individual viewer identification is not provided for general share links. + +**Do embedded views count in analytics?** + +Yes. Views from embedded iframes are counted the same as views from direct share links. + +**Are my own views counted?** + +Views from the recording owner may be included in the count. For the most accurate audience metrics, refer to the unique views count which provides a better picture of your actual audience. + +**How quickly do analytics update?** + +Analytics are updated in near real-time. New views typically appear within a few minutes of the recording being watched. + diff --git a/apps/web/content/docs/sharing/comments.mdx b/apps/web/content/docs/sharing/comments.mdx new file mode 100644 index 0000000000..b2acc33966 --- /dev/null +++ b/apps/web/content/docs/sharing/comments.mdx @@ -0,0 +1,114 @@ +--- +title: "Comments" +summary: "Collaborate with timestamped comments on recordings" +tags: "Sharing, Collaboration" +--- + +Comments let viewers leave feedback directly on your Cap recordings. Every comment is tied to a specific timestamp in the video, making it easy to have precise, contextual conversations about what is on screen. + +## How Comments Work + +When someone watches your shared recording, they can click on the video timeline or use the comment input to leave a message at a specific moment. The comment is anchored to that exact timestamp, so when you or other viewers click on the comment, the video jumps to the relevant moment. + +This makes comments far more useful than generic text feedback. Instead of writing "at around the two minute mark, the button looks wrong," viewers can click the exact frame and write "this button looks wrong." + +## Leaving a Comment + +To leave a comment on a shared recording: + +1. Open the shared Cap link in your browser. +2. Watch the video or scrub to the moment you want to comment on. +3. Click the **comment input** below the video. +4. Type your message. +5. Press **Enter** or click the **Send** button. + +Your comment is posted and anchored to the current timestamp in the video. + +## Timestamped Comments + +Every comment shows the timestamp it was left at. For example, a comment left at 1 minute and 23 seconds will display as **1:23** next to the message. + +When you click on a comment's timestamp: + +- The video scrubs to that exact moment +- The relevant section plays so you can see what the commenter is referring to + +This makes it easy to review feedback point by point without manually searching through the video. + +## Emoji Reactions + +In addition to text comments, viewers can leave emoji reactions on recordings. Reactions are a quick, lightweight way to express feedback without writing a full comment. + +Common uses for reactions: + +- Celebrating a great demo or walkthrough +- Marking sections that are confusing or need attention +- Acknowledging that you have seen and understood the content + +## Viewing Comments + +### As the Recording Owner + +All comments on your recordings are visible in several places: + +- **On the share page** - Comments appear below the video player in chronological order. +- **On your dashboard** - Your [Cap dashboard](https://cap.so/dashboard) shows notifications for new comments on your recordings. + +### Comment Notifications + +When someone leaves a comment on your recording, you receive a notification. This keeps you informed about feedback without having to manually check each recording. + +Notification methods include: + +- **In-app notifications** - Visible on your Cap dashboard. +- **Email notifications** - Receive an email when new comments are posted. + +You can manage your notification preferences from your account settings. + +## Comment Moderation + +As the owner of a recording, you have control over the comments on it: + +### Deleting Comments + +To remove an inappropriate or unwanted comment: + +1. Navigate to the recording on your dashboard or share page. +2. Find the comment you want to remove. +3. Click the menu icon on the comment. +4. Select **Delete**. + +The comment is permanently removed and will no longer be visible to anyone. + +## Use Cases for Comments + +### Code Reviews + +Record yourself walking through a pull request, share the Cap link in the PR, and let reviewers leave timestamped comments pointing out specific lines or patterns they want to discuss. + +### Design Feedback + +Share a recording of a design prototype and let stakeholders leave precise feedback at the exact moments where they have suggestions or concerns. + +### Bug Reports + +Record a bug reproduction and share the link. QA teammates or developers can comment at the exact moment the bug occurs, adding technical context or reproduction details. + +### Training and Onboarding + +Create training recordings for new team members. They can leave comments with questions at the specific moments where they need clarification, and you can respond directly at those timestamps. + +### Client Presentations + +Share project demos with clients and let them leave feedback at specific points. This is more structured than email threads and keeps all feedback attached to the relevant context. + +### Async Standups + +Record your daily update and share it with your team. Team members can leave reactions to acknowledge the update or comments to ask follow-up questions about specific items. + +## Best Practices + +- **Be specific.** Since comments are timestamped, reference what is currently on screen rather than describing it from memory. +- **Use reactions for acknowledgment.** If you have watched a recording and have no detailed feedback, a quick emoji reaction lets the creator know you have seen it. +- **Respond at the right timestamp.** When replying to feedback, scrub to the relevant moment before commenting so your response is anchored to the same context. +- **Keep it constructive.** Comments are meant to facilitate collaboration. Focus on actionable feedback and clear questions. diff --git a/apps/web/content/docs/sharing/embeds.mdx b/apps/web/content/docs/sharing/embeds.mdx new file mode 100644 index 0000000000..8451858268 --- /dev/null +++ b/apps/web/content/docs/sharing/embeds.mdx @@ -0,0 +1,170 @@ +--- +title: "Embeds" +summary: "Embed Cap recordings in websites and apps" +tags: "Sharing, Integration" +--- + +Cap recordings can be embedded directly in websites, documentation, blogs, and web applications using a simple iframe. Embeds let your audience watch recordings inline without leaving the page. + +## Embed URL Format + +Every Cap recording has a dedicated embed URL: + +``` +https://cap.so/embed/[video-id] +``` + +If you are self-hosting Cap, replace `Cap.so` with your custom domain: + +``` +https://your-domain.com/embed/[video-id] +``` + +You can find the video ID in your recording's share link. For example, if your share link is `https://cap.so/s/abc123`, the embed URL would be `https://cap.so/embed/abc123`. + +## Basic Embed + +Add this HTML to any webpage to embed a Cap recording: + +```html + +``` + +Replace `[video-id]` with your actual video ID. + +## Responsive Embed + +To make your embed responsive and maintain the correct aspect ratio across screen sizes, wrap the iframe in a container with a percentage-based padding: + +```html +
+ +
+``` + +The `padding-bottom: 56.25%` value creates a 16:9 aspect ratio container. Adjust this value if your recordings use a different aspect ratio: + +| Aspect Ratio | Padding Bottom | +|--------------|---------------| +| 16:9 | 56.25% | +| 4:3 | 75% | +| 21:9 | 42.86% | +| 1:1 | 100% | + +## Embed in MDX / React + +If you are using MDX or a React-based site (Next.js, Gatsby, Docusaurus), you can embed directly with JSX: + +```jsx +
+ +
+``` + +## Troubleshooting + +**Embed is not loading**: Verify the video ID is correct and the recording has not been deleted. Check that the embed URL uses `https://`. + +**Embed is blocked**: Some corporate networks or browser extensions block iframes. Ask the viewer to try a different network or disable iframe-blocking extensions. + +**Aspect ratio looks wrong**: Adjust the `padding-bottom` percentage in the responsive container to match your recording's actual aspect ratio. + +**Password-protected videos**: Embeds of password-protected recordings will show a password prompt. The viewer must enter the correct password to watch. diff --git a/apps/web/content/docs/sharing/share-a-cap.mdx b/apps/web/content/docs/sharing/share-a-cap.mdx new file mode 100644 index 0000000000..570f79d872 --- /dev/null +++ b/apps/web/content/docs/sharing/share-a-cap.mdx @@ -0,0 +1,124 @@ +--- +title: "Share a Cap" +summary: "Share your recordings with a link" +tags: "Sharing" +--- + +Every Cap recording gets a unique shareable link. This guide covers how sharing works, how to customize your share settings, and what viewers see when they open your link. + +## How Sharing Works + +When you finish a recording in Instant Mode, Cap automatically generates a shareable link and copies it to your clipboard. In Studio Mode, the link is generated after you finish editing and upload the recording. + +The link follows this format: + +``` +https://cap.so/s/[video-id] +``` + +If you are self-hosting Cap, the link uses your custom domain instead. + +Anyone with the link can watch your recording in their browser. No account or download is required to view a shared Cap. + +## Copying the Link + +After recording, the link is automatically copied to your clipboard. You can also find and copy the link from: + +- **Cap Desktop** - Click the share icon on any recording in your library. +- **Cap Dashboard** - Navigate to [Cap.so/dashboard](https://cap.so/dashboard), find your recording, and click the share button. + +## Customizing Share Settings + +You can customize how your shared recordings appear and control who can access them. + +### Title and Description + +Give your recording a descriptive title so viewers know what they are about to watch: + +1. Open your [Cap dashboard](https://cap.so/dashboard). +2. Click on the recording you want to update. +3. Edit the title field at the top of the page. + +A good title helps viewers find your recording later and sets expectations before they press play. + +### Password Protection + +Restrict access to your recording by setting a password: + +1. Open the recording's settings on your dashboard. +2. Enable **Password Protection**. +3. Enter a password. +4. Save your changes. + +When viewers open the link, they will be prompted to enter the password before they can watch. This is useful for sensitive content, internal demos, or client recordings. + +## The Viewer Experience + +When someone opens your shared link, they see a clean video player page with: + +### Video Player + +- High-quality video playback +- Play, pause, and scrub controls +- Fullscreen mode +- Adjustable playback speed (0.5x, 0.75x, 1x, 1.25x, 1.5x, 1.75x, 2x) +- Volume control + +### AI Captions + +Cap automatically generates captions for recordings that include audio. Viewers can: + +- Toggle captions on and off +- Read along as the video plays +- Captions are synced to the audio timeline + +### Comments + +Viewers can leave timestamped comments on your recording. See [Comments](/docs/sharing/comments) for more details. + +### Download + +Viewers can download the video file directly from the share page. + +## Sharing to Different Platforms + +### Slack and Teams + +Paste your Cap link directly into a Slack or Microsoft Teams message. Most platforms will show a rich preview with the recording title and thumbnail. + +### Email + +Include the Cap link in any email. Recipients click the link to watch in their browser. + +### GitHub and GitLab + +Paste the link in issues, pull requests, or comments. This is great for bug reports, feature demonstrations, and code review walkthroughs. + +### Documentation + +Embed your recordings directly in documentation sites using an iframe. See [Embeds](/docs/sharing/embeds) for embed instructions. + +### Social Media + +Share the link on Twitter/X, LinkedIn, or any social platform. The link will display a preview card with your recording's thumbnail and title. + +## Managing Shared Recordings + +### Viewing All Recordings + +Your [Cap dashboard](https://cap.so/dashboard) shows all of your recordings with their share links, view counts, and creation dates. Use the dashboard to manage, organize, and review your content. + +### Deleting a Recording + +To remove a shared recording: + +1. Open your [Cap dashboard](https://cap.so/dashboard). +2. Find the recording you want to delete. +3. Click the menu icon and select **Delete**. +4. Confirm the deletion. + +Once deleted, the share link will no longer work and viewers will see a "not found" page. + +### Revoking Access + +If you need to quickly restrict access to a recording without deleting it, enable password protection. Existing viewers without the password will no longer be able to watch it. diff --git a/apps/web/lib/developer-credits.ts b/apps/web/lib/developer-credits.ts new file mode 100644 index 0000000000..da79b7464b --- /dev/null +++ b/apps/web/lib/developer-credits.ts @@ -0,0 +1,64 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + type DeveloperCreditReferenceType, + developerCreditAccounts, + developerCreditTransactions, +} from "@cap/database/schema"; +import { eq, sql } from "drizzle-orm"; + +const MICRO_CREDITS_PER_DOLLAR = 100_000; + +export async function addCreditsToAccount({ + accountId, + amountCents, + referenceId, + referenceType, + metadata, +}: { + accountId: string; + amountCents: number; + referenceId?: string; + referenceType?: DeveloperCreditReferenceType; + metadata?: Record; +}): Promise { + const microCreditsToAdd = Math.floor( + (amountCents / 100) * MICRO_CREDITS_PER_DOLLAR, + ); + + const newBalance = await db().transaction(async (tx) => { + await tx + .update(developerCreditAccounts) + .set({ + balanceMicroCredits: sql`${developerCreditAccounts.balanceMicroCredits} + ${microCreditsToAdd}`, + }) + .where(eq(developerCreditAccounts.id, accountId)); + + const [updated] = await tx + .select({ + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.id, accountId)) + .limit(1); + + if (!updated) { + throw new Error(`Credit account not found: ${accountId}`); + } + + await tx.insert(developerCreditTransactions).values({ + id: nanoId(), + accountId, + type: "topup", + amountMicroCredits: microCreditsToAdd, + balanceAfterMicroCredits: updated.balanceMicroCredits, + referenceId, + referenceType, + metadata: metadata ?? { amountCents }, + }); + + return updated.balanceMicroCredits; + }); + + return newBalance; +} diff --git a/apps/web/lib/developer-key-hash.ts b/apps/web/lib/developer-key-hash.ts new file mode 100644 index 0000000000..d52c4f62d7 --- /dev/null +++ b/apps/web/lib/developer-key-hash.ts @@ -0,0 +1,30 @@ +import { serverEnv } from "@cap/env"; + +let hmacKeyCache: CryptoKey | null = null; + +async function getHmacKey(): Promise { + if (hmacKeyCache) return hmacKeyCache; + const secret = serverEnv().NEXTAUTH_SECRET; + const encoder = new TextEncoder(); + hmacKeyCache = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return hmacKeyCache; +} + +export async function hashKey(key: string): Promise { + const hmacKey = await getHmacKey(); + const encoder = new TextEncoder(); + const signature = await crypto.subtle.sign( + "HMAC", + hmacKey, + encoder.encode(key), + ); + return Array.from(new Uint8Array(signature)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/apps/web/package.json b/apps/web/package.json index af33d0316a..f85284e615 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -129,10 +129,12 @@ "react-scroll-parallax": "^3.4.5", "react-tooltip": "^5.26.3", "recharts": "^3.3.0", + "rehype-pretty-code": "^0.14.1", "remark-gfm": "^4.0.1", "replicate": "^1.4.0", "resend": "4.6.0", "server-only": "^0.0.1", + "shiki": "3", "sonner": "^2.0.3", "subtitles-parser-vtt": "^0.1.0", "supermemory": "^4.11.1", diff --git a/apps/web/utils/docs.ts b/apps/web/utils/docs.ts new file mode 100644 index 0000000000..84b0a89e06 --- /dev/null +++ b/apps/web/utils/docs.ts @@ -0,0 +1,153 @@ +import fs from "node:fs"; +import path from "node:path"; +import { cache } from "react"; + +export interface DocMetadata { + title: string; + summary: string; + description?: string; + tags?: string; + image?: string; +} + +export interface Doc { + metadata: DocMetadata; + slug: string; + content: string; +} + +export interface DocHeading { + level: number; + text: string; + slug: string; +} + +function parseFrontmatter(fileContent: string) { + const frontmatterRegex = /---\s*([\s\S]*?)\s*---/; + const match = frontmatterRegex.exec(fileContent); + if (!match?.[1]) { + throw new Error("Invalid or missing frontmatter"); + } + + const frontMatterBlock = match[1]; + const content = fileContent.replace(frontmatterRegex, "").trim(); + const frontMatterLines = frontMatterBlock.trim().split("\n"); + const metadata: Partial = {}; + + for (const line of frontMatterLines) { + const [key, ...valueArr] = line.split(": "); + if (!key) continue; + let value = valueArr.join(": ").trim(); + value = value.replace(/^['"](.*)['"]$/, "$1"); + (metadata as Record)[key.trim()] = value; + } + + return { metadata: metadata as DocMetadata, content }; +} + +function getMDXFiles(dir: string): string[] { + const files: string[] = []; + + function scanDir(currentDir: string) { + const entries = fs.readdirSync(currentDir); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + scanDir(fullPath); + } else if (path.extname(entry) === ".mdx") { + files.push(path.relative(dir, fullPath)); + } + } + } + + scanDir(dir); + return files; +} + +const docsDir = path.join(process.cwd(), "content/docs"); + +export const getAllDocs = cache(function getAllDocs(): Doc[] { + const mdxFiles = getMDXFiles(docsDir); + return mdxFiles.map((relativePath) => { + const fullPath = path.join(docsDir, relativePath); + const { metadata, content } = parseFrontmatter( + fs.readFileSync(fullPath, "utf-8"), + ); + const slug = relativePath + .replace(/\.mdx$/, "") + .split(path.sep) + .join("/"); + return { metadata, slug, content }; + }); +}); + +export function getDocBySlug(slug: string): Doc | undefined { + const filePath = path.resolve(docsDir, `${slug}.mdx`); + if (!filePath.startsWith(docsDir + path.sep)) return undefined; + if (!fs.existsSync(filePath)) return undefined; + const { metadata, content } = parseFrontmatter( + fs.readFileSync(filePath, "utf-8"), + ); + return { metadata, slug, content }; +} + +export function extractHeadings(content: string): DocHeading[] { + const headingRegex = /^(#{2,3})\s+(.+)$/gm; + const headings: DocHeading[] = []; + for (const match of content.matchAll(headingRegex)) { + const level = match[1]!.length; + const text = match[2]!.trim(); + const slug = text + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/&/g, "-and-") + .replace(/[^\w-]+/g, "") + .replace(/--+/g, "-"); + headings.push({ level, text, slug }); + } + return headings; +} + +export function getDocSearchIndex( + sidebar: Array<{ + title: string; + items: Array<{ slug: string; title: string }>; + }>, +): Array<{ + slug: string; + title: string; + summary: string; + content: string; + group: string; +}> { + const docs = getAllDocs(); + + return docs.map((doc) => { + let group = ""; + for (const section of sidebar) { + if (section.items.some((item) => item.slug === doc.slug)) { + group = section.title; + break; + } + } + + const plainContent = doc.content + .replace(/---[\s\S]*?---/, "") + .replace(/```[\s\S]*?```/g, "") + .replace(/<[^>]+>/g, "") + .replace(/[#*`[\]()]/g, "") + .replace(/[<>]/g, "") + .replace(/\n+/g, " ") + .trim() + .slice(0, 500); + + return { + slug: doc.slug, + title: doc.metadata.title, + summary: doc.metadata.summary || "", + content: plainContent, + group, + }; + }); +} diff --git a/package.json b/package.json index 47f69790ef..9ec212b2bc 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "db:generate": "dotenv -e .env -- pnpm --filter @cap/database db:generate", "db:push": "dotenv -e .env -- pnpm --filter @cap/database db:push", "db:studio": "dotenv -e .env -- pnpm --filter @cap/database db:studio", - "dev": "(pnpm run docker:up > /dev/null &) && sleep 5 && trap 'pnpm run docker:stop' EXIT && dotenv -e .env -- turbo run dev --env-mode=loose --ui tui", + "dev": "pnpm run docker:up && trap 'pnpm run docker:stop' EXIT && dotenv -e .env -- turbo run dev --env-mode=loose --ui tui", "dev:desktop": "pnpm run --filter=@cap/desktop dev", "dev:manual": "pnpm run docker:up && trap 'pnpm run docker:stop' EXIT && dotenv -e .env -- turbo run dev --filter=!@cap/storybook --no-cache --concurrency 1", "dev:web": "pnpm dev --filter=!@cap/desktop", diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 125d40cb7d..7d214cb648 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -15,6 +15,7 @@ import { customType, datetime, float, + foreignKey, index, int, json, @@ -885,3 +886,280 @@ export const importedVideos = mysqlTable( primaryKey({ columns: [table.orgId, table.source, table.sourceId] }), ], ); + +export const developerApps = mysqlTable( + "developer_apps", + { + id: nanoId("id").notNull().primaryKey(), + ownerId: nanoId("ownerId").notNull().$type(), + name: varchar("name", { length: 255 }).notNull(), + environment: varchar("environment", { + length: 16, + enum: ["development", "production"], + }).notNull(), + logoUrl: varchar("logoUrl", { length: 1024 }), + deletedAt: timestamp("deletedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => [index("owner_deleted_idx").on(table.ownerId, table.deletedAt)], +); + +export const developerAppDomains = mysqlTable( + "developer_app_domains", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + domain: varchar("domain", { length: 253 }).notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => [unique("app_domain_unique").on(table.appId, table.domain)], +); + +export const developerApiKeys = mysqlTable( + "developer_api_keys", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + keyType: varchar("keyType", { + length: 8, + enum: ["public", "secret"], + }).notNull(), + keyPrefix: varchar("keyPrefix", { length: 12 }).notNull(), + keyHash: varchar("keyHash", { length: 64 }).notNull(), + encryptedKey: encryptedText("encryptedKey").notNull(), + lastUsedAt: timestamp("lastUsedAt"), + revokedAt: timestamp("revokedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("key_hash_idx").on(table.keyHash), + index("app_key_type_idx").on(table.appId, table.keyType), + ], +); + +export const developerVideos = mysqlTable( + "developer_videos", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + externalUserId: varchar("externalUserId", { length: 255 }), + name: varchar("name", { length: 255 }).notNull().default("Untitled"), + duration: float("duration"), + width: int("width"), + height: int("height"), + fps: int("fps"), + s3Key: varchar("s3Key", { length: 512 }), + transcriptionStatus: varchar("transcriptionStatus", { + length: 16, + enum: ["PROCESSING", "COMPLETE", "ERROR", "SKIPPED", "NO_AUDIO"], + }), + metadata: json("metadata"), + deletedAt: timestamp("deletedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => [ + index("app_created_idx").on(table.appId, table.createdAt), + index("app_user_idx").on(table.appId, table.externalUserId), + index("app_deleted_idx").on(table.appId, table.deletedAt), + ], +); + +export const developerCreditAccounts = mysqlTable( + "developer_credit_accounts", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + ownerId: nanoId("ownerId").notNull().$type(), + balanceMicroCredits: bigint("balanceMicroCredits", { + mode: "number", + unsigned: true, + }) + .notNull() + .default(0), + stripeCustomerId: varchar("stripeCustomerId", { length: 255 }), + stripePaymentMethodId: varchar("stripePaymentMethodId", { length: 255 }), + autoTopUpEnabled: boolean("autoTopUpEnabled").notNull().default(false), + autoTopUpThresholdMicroCredits: bigint("autoTopUpThresholdMicroCredits", { + mode: "number", + unsigned: true, + }) + .notNull() + .default(0), + autoTopUpAmountCents: int("autoTopUpAmountCents").notNull().default(0), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => [uniqueIndex("app_id_unique").on(table.appId)], +); + +export type DeveloperCreditTransactionType = + | "topup" + | "video_create" + | "storage_daily" + | "refund" + | "adjustment"; + +export type DeveloperCreditReferenceType = + | "developer_video" + | "stripe_payment_intent" + | "manual"; + +export const developerCreditTransactions = mysqlTable( + "developer_credit_transactions", + { + id: nanoId("id").notNull().primaryKey(), + accountId: nanoId("accountId").notNull(), + type: varchar("type", { + length: 16, + enum: ["topup", "video_create", "storage_daily", "refund", "adjustment"], + }) + .notNull() + .$type(), + amountMicroCredits: bigint("amountMicroCredits", { + mode: "number", + }).notNull(), + balanceAfterMicroCredits: bigint("balanceAfterMicroCredits", { + mode: "number", + unsigned: true, + }).notNull(), + referenceId: varchar("referenceId", { length: 255 }), + referenceType: varchar("referenceType", { + length: 32, + enum: ["developer_video", "stripe_payment_intent", "manual"], + }).$type(), + metadata: json("metadata"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => [ + foreignKey({ + name: "dev_credit_txn_account_fk", + columns: [table.accountId], + foreignColumns: [developerCreditAccounts.id], + }), + index("account_type_created_idx").on( + table.accountId, + table.type, + table.createdAt, + ), + index("account_ref_dedup_idx").on( + table.accountId, + table.referenceId, + table.referenceType, + ), + ], +); + +export const developerDailyStorageSnapshots = mysqlTable( + "developer_daily_storage_snapshots", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + snapshotDate: varchar("snapshotDate", { length: 10 }).notNull(), + totalDurationMinutes: float("totalDurationMinutes").notNull().default(0), + videoCount: int("videoCount").notNull().default(0), + microCreditsCharged: bigint("microCreditsCharged", { + mode: "number", + unsigned: true, + }) + .notNull() + .default(0), + processedAt: timestamp("processedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => [unique("app_date_unique").on(table.appId, table.snapshotDate)], +); + +export const developerAppsRelations = relations( + developerApps, + ({ one, many }) => ({ + owner: one(users, { + fields: [developerApps.ownerId], + references: [users.id], + }), + domains: many(developerAppDomains), + apiKeys: many(developerApiKeys), + videos: many(developerVideos), + creditAccount: one(developerCreditAccounts, { + fields: [developerApps.id], + references: [developerCreditAccounts.appId], + }), + storageSnapshots: many(developerDailyStorageSnapshots), + }), +); + +export const developerAppDomainsRelations = relations( + developerAppDomains, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerAppDomains.appId], + references: [developerApps.id], + }), + }), +); + +export const developerApiKeysRelations = relations( + developerApiKeys, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerApiKeys.appId], + references: [developerApps.id], + }), + }), +); + +export const developerVideosRelations = relations( + developerVideos, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerVideos.appId], + references: [developerApps.id], + }), + }), +); + +export const developerCreditAccountsRelations = relations( + developerCreditAccounts, + ({ one, many }) => ({ + app: one(developerApps, { + fields: [developerCreditAccounts.appId], + references: [developerApps.id], + }), + owner: one(users, { + fields: [developerCreditAccounts.ownerId], + references: [users.id], + }), + transactions: many(developerCreditTransactions), + }), +); + +export const developerCreditTransactionsRelations = relations( + developerCreditTransactions, + ({ one }) => ({ + account: one(developerCreditAccounts, { + fields: [developerCreditTransactions.accountId], + references: [developerCreditAccounts.id], + }), + }), +); + +export const developerDailyStorageSnapshotsRelations = relations( + developerDailyStorageSnapshots, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerDailyStorageSnapshots.appId], + references: [developerApps.id], + }), + }), +); diff --git a/packages/local-docker/package.json b/packages/local-docker/package.json index 0bbc840a60..a68d75430c 100644 --- a/packages/local-docker/package.json +++ b/packages/local-docker/package.json @@ -5,7 +5,7 @@ "types": "./src/index.tsx", "scripts": { "dev": "pnpm docker:up", - "docker:up": "docker compose up", + "docker:up": "docker compose up -d --wait", "docker:stop": "docker compose stop", "docker:clean": "docker compose down -v" }, diff --git a/packages/sdk-embed/package.json b/packages/sdk-embed/package.json new file mode 100644 index 0000000000..0c732bce52 --- /dev/null +++ b/packages/sdk-embed/package.json @@ -0,0 +1,44 @@ +{ + "name": "@cap/sdk-embed", + "version": "0.1.0", + "description": "Cap Embedding SDK for third-party integrations", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js" + }, + "./vanilla": { + "types": "./dist/vanilla/cap-embed-loader.d.ts", + "import": "./dist/vanilla/cap-embed-loader.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit" + }, + "dependencies": {}, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "tsup": "^8.0.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/sdk-embed/src/index.ts b/packages/sdk-embed/src/index.ts new file mode 100644 index 0000000000..f26bd4ede6 --- /dev/null +++ b/packages/sdk-embed/src/index.ts @@ -0,0 +1,2 @@ +export type { EmbedOptions } from "./types"; +export { createEmbedUrl } from "./vanilla/cap-embed"; diff --git a/packages/sdk-embed/src/react/CapEmbed.tsx b/packages/sdk-embed/src/react/CapEmbed.tsx new file mode 100644 index 0000000000..e8fb2e4a2d --- /dev/null +++ b/packages/sdk-embed/src/react/CapEmbed.tsx @@ -0,0 +1,37 @@ +import type { CSSProperties } from "react"; +import type { EmbedOptions } from "../types"; +import { createEmbedUrl } from "../vanilla/cap-embed"; + +interface CapEmbedProps extends EmbedOptions { + className?: string; + style?: CSSProperties; + width?: string | number; + height?: string | number; +} + +export function CapEmbed({ + className, + style, + width = "100%", + height = "100%", + ...options +}: CapEmbedProps) { + const src = createEmbedUrl(options); + + return ( +