diff --git a/apps/web/__tests__/unit/integration-installations-repository.test.ts b/apps/web/__tests__/unit/integration-installations-repository.test.ts new file mode 100644 index 00000000000..73a5d9b06e5 --- /dev/null +++ b/apps/web/__tests__/unit/integration-installations-repository.test.ts @@ -0,0 +1,265 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Organisation, User } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const databaseMocks = vi.hoisted(() => { + const state = { rows: [] as unknown[] }; + const onDuplicateKeyUpdate = vi.fn( + async (_input: { set: Record }) => undefined, + ); + const values = vi.fn(() => ({ onDuplicateKeyUpdate })); + const insert = vi.fn(() => ({ values })); + const orderBy = vi.fn(async () => state.rows); + const limit = vi.fn(async () => state.rows); + const forUpdate = vi.fn(async () => state.rows); + const where = vi.fn(() => ({ orderBy, limit, for: forUpdate })); + const from = vi.fn(() => ({ where })); + const select = vi.fn(() => ({ from })); + const updateWhere = vi.fn(async () => undefined); + const set = vi.fn(() => ({ where: updateWhere })); + const update = vi.fn(() => ({ set })); + const transaction = vi.fn( + async ( + callback: (tx: { + insert: typeof insert; + select: typeof select; + update: typeof update; + }) => Promise, + ) => callback({ insert, select, update }), + ); + const deleteWhere = vi.fn(async () => undefined); + const deleteRows = vi.fn(() => ({ where: deleteWhere })); + + return { + state, + onDuplicateKeyUpdate, + values, + insert, + orderBy, + limit, + forUpdate, + where, + from, + select, + updateWhere, + set, + update, + transaction, + deleteWhere, + deleteRows, + }; +}); + +const cryptoMocks = vi.hoisted(() => ({ + decrypt: vi.fn(), + encrypt: vi.fn(), +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + insert: databaseMocks.insert, + select: databaseMocks.select, + update: databaseMocks.update, + delete: databaseMocks.deleteRows, + transaction: databaseMocks.transaction, + }), +})); + +vi.mock("@cap/database/crypto", () => cryptoMocks); +vi.mock("@cap/database/helpers", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + nanoId: () => "installation123", + }; +}); + +import { + deleteIntegrationInstallation, + deleteIntegrationInstallationByExternalId, + getIntegrationCredentials, + listIntegrationInstallations, + saveIntegrationInstallation, +} from "@/lib/integrations/installations"; + +const organizationId = Organisation.OrganisationId.make("org123"); +const installedByUserId = User.UserId.make("user123"); + +describe("integration installation repository", () => { + beforeEach(() => { + vi.clearAllMocks(); + databaseMocks.state.rows = []; + cryptoMocks.encrypt.mockResolvedValue("encrypted-credentials"); + cryptoMocks.decrypt.mockResolvedValue("{}"); + }); + + it("updates credentials only after locking the owning tenant installation", async () => { + databaseMocks.state.rows = [ + { + id: "existing-installation", + organizationId, + }, + ]; + + await saveIntegrationInstallation({ + provider: "slack", + externalId: "T123", + displayName: "Cap", + organizationId, + installedByUserId, + credentials: { accessToken: "xoxb-token" }, + metadata: { scopes: ["links:read"] }, + }); + + expect(cryptoMocks.encrypt).toHaveBeenCalledWith( + JSON.stringify({ accessToken: "xoxb-token" }), + ); + expect(databaseMocks.values).toHaveBeenCalledWith({ + id: "installation123", + provider: "slack", + externalId: "T123", + displayName: "Cap", + organizationId, + installedByUserId, + encryptedCredentials: "encrypted-credentials", + metadata: { scopes: ["links:read"] }, + }); + + expect(databaseMocks.onDuplicateKeyUpdate).toHaveBeenCalledOnce(); + const [upsert] = databaseMocks.onDuplicateKeyUpdate.mock.calls[0] ?? []; + expect(upsert?.set).toEqual({ + externalId: expect.anything(), + }); + expect(upsert?.set).not.toHaveProperty("organizationId"); + expect(databaseMocks.forUpdate).toHaveBeenCalledWith("update"); + expect(databaseMocks.set).toHaveBeenCalledWith({ + displayName: "Cap", + installedByUserId, + encryptedCredentials: "encrypted-credentials", + metadata: { scopes: ["links:read"] }, + updatedAt: expect.any(Date), + }); + expect(databaseMocks.updateWhere).toHaveBeenCalledOnce(); + }); + + it("rejects an installation already owned by another organization", async () => { + databaseMocks.state.rows = [ + { + id: "existing-installation", + organizationId: Organisation.OrganisationId.make("other-org"), + }, + ]; + + await expect( + saveIntegrationInstallation({ + provider: "slack", + externalId: "T123", + displayName: "Cap", + organizationId, + installedByUserId, + credentials: { accessToken: "xoxb-token" }, + metadata: { scopes: ["links:read"] }, + }), + ).rejects.toThrow( + "Integration is already connected to another organization", + ); + expect(databaseMocks.set).not.toHaveBeenCalled(); + }); + + it("ships a provider-agnostic schema with workload-matched indexes", () => { + const migration = readFileSync( + join( + process.cwd(), + "../../packages/database/migrations/0037_integration_installations.sql", + ), + "utf8", + ); + + expect(migration).toContain("CREATE TABLE `integration_installations`"); + expect(migration).toContain( + "CONSTRAINT `provider_external_id_idx` UNIQUE(`provider`,`externalId`)", + ); + expect(migration).toContain( + "CREATE INDEX `organization_provider_display_name_idx` ON `integration_installations` (`organizationId`,`provider`,`displayName`)", + ); + expect(migration).not.toContain("slack_installations"); + expect(migration).not.toContain("encryptedBotToken"); + }); + + it("lists only the non-secret installation summary shape", async () => { + const rows = [ + { + id: "installation123", + externalId: "T123", + displayName: "Cap", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-02T00:00:00Z"), + }, + ]; + databaseMocks.state.rows = rows; + + await expect( + listIntegrationInstallations({ + organizationId, + provider: "slack", + }), + ).resolves.toEqual(rows); + expect(databaseMocks.orderBy).toHaveBeenCalledOnce(); + }); + + it("decrypts and parses credentials only after an exact lookup", async () => { + databaseMocks.state.rows = [ + { encryptedCredentials: "encrypted-credentials" }, + ]; + cryptoMocks.decrypt.mockResolvedValue( + JSON.stringify({ accessToken: "xoxb-token" }), + ); + + await expect( + getIntegrationCredentials({ + provider: "slack", + externalId: "T123", + }), + ).resolves.toEqual({ accessToken: "xoxb-token" }); + expect(cryptoMocks.decrypt).toHaveBeenCalledWith("encrypted-credentials"); + expect(databaseMocks.limit).toHaveBeenCalledWith(1); + + databaseMocks.state.rows = []; + await expect( + getIntegrationCredentials({ + provider: "slack", + externalId: "missing", + }), + ).resolves.toBeNull(); + }); + + it("fails closed when encrypted credentials are malformed", async () => { + databaseMocks.state.rows = [ + { encryptedCredentials: "encrypted-credentials" }, + ]; + cryptoMocks.decrypt.mockResolvedValue("not-json"); + + await expect( + getIntegrationCredentials({ + provider: "slack", + externalId: "T123", + }), + ).rejects.toThrow(); + }); + + it("supports tenant-scoped disconnects and provider-scoped uninstalls", async () => { + await deleteIntegrationInstallation({ + provider: "slack", + organizationId, + installationId: "installation123", + }); + await deleteIntegrationInstallationByExternalId({ + provider: "slack", + externalId: "T123", + }); + + expect(databaseMocks.deleteRows).toHaveBeenCalledTimes(2); + expect(databaseMocks.deleteWhere).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/__tests__/unit/integration-installations.test.ts b/apps/web/__tests__/unit/integration-installations.test.ts new file mode 100644 index 00000000000..368602930ce --- /dev/null +++ b/apps/web/__tests__/unit/integration-installations.test.ts @@ -0,0 +1,123 @@ +import { Organisation, User } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const integrationMocks = vi.hoisted(() => ({ + deleteIntegrationInstallation: vi.fn(async () => undefined), + deleteIntegrationInstallationByExternalId: vi.fn(async () => undefined), + getIntegrationCredentials: vi.fn(), + listIntegrationInstallations: vi.fn(), + saveIntegrationInstallation: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/integrations/installations", () => integrationMocks); + +import { + deleteSlackInstallation, + deleteSlackInstallationByTeamId, + getSlackInstallationToken, + listSlackInstallations, + saveSlackInstallation, +} from "@/lib/slack/installations"; + +const organizationId = Organisation.OrganisationId.make("org123"); +const installedByUserId = User.UserId.make("user123"); + +describe("integration installations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("stores Slack through the provider-agnostic installation boundary", async () => { + await saveSlackInstallation({ + organizationId, + installedByUserId, + teamId: "T123", + teamName: "Cap", + enterpriseId: "E123", + botUserId: "U123", + accessToken: "xoxb-token", + scope: "links:read, links:write,links.embed:write", + }); + + expect(integrationMocks.saveIntegrationInstallation).toHaveBeenCalledWith({ + provider: "slack", + externalId: "T123", + displayName: "Cap", + organizationId, + installedByUserId, + credentials: { accessToken: "xoxb-token" }, + metadata: { + enterpriseId: "E123", + botUserId: "U123", + scopes: ["links:read", "links:write", "links.embed:write"], + }, + }); + }); + + it("maps generic installation summaries to Slack workspaces", async () => { + const createdAt = new Date("2026-01-01T00:00:00Z"); + const updatedAt = new Date("2026-01-02T00:00:00Z"); + integrationMocks.listIntegrationInstallations.mockResolvedValue([ + { + id: "installation123", + externalId: "T123", + displayName: "Cap", + createdAt, + updatedAt, + }, + ]); + + await expect(listSlackInstallations(organizationId)).resolves.toEqual([ + { + id: "installation123", + teamId: "T123", + teamName: "Cap", + createdAt, + updatedAt, + }, + ]); + expect(integrationMocks.listIntegrationInstallations).toHaveBeenCalledWith({ + organizationId, + provider: "slack", + }); + }); + + it("reads only valid provider credentials", async () => { + integrationMocks.getIntegrationCredentials.mockResolvedValue({ + accessToken: "xoxb-token", + }); + await expect(getSlackInstallationToken("T123")).resolves.toBe("xoxb-token"); + + integrationMocks.getIntegrationCredentials.mockResolvedValue(null); + await expect(getSlackInstallationToken("T123")).resolves.toBeNull(); + + integrationMocks.getIntegrationCredentials.mockResolvedValue({ + accessToken: 123, + }); + await expect(getSlackInstallationToken("T123")).rejects.toThrow( + "Slack installation credentials are invalid", + ); + }); + + it("keeps all deletion paths provider-scoped", async () => { + await deleteSlackInstallation({ + organizationId, + installationId: "installation123", + }); + await deleteSlackInstallationByTeamId("T123"); + + expect(integrationMocks.deleteIntegrationInstallation).toHaveBeenCalledWith( + { + provider: "slack", + organizationId, + installationId: "installation123", + }, + ); + expect( + integrationMocks.deleteIntegrationInstallationByExternalId, + ).toHaveBeenCalledWith({ + provider: "slack", + externalId: "T123", + }); + }); +}); diff --git a/apps/web/__tests__/unit/oembed.test.ts b/apps/web/__tests__/unit/oembed.test.ts new file mode 100644 index 00000000000..eba9f7021f2 --- /dev/null +++ b/apps/web/__tests__/unit/oembed.test.ts @@ -0,0 +1,89 @@ +import { Video } from "@cap/web-domain"; +import { describe, expect, it } from "vitest"; +import { buildOEmbedVideo, parseCapShareUrl } from "@/lib/oembed"; +import type { PublicShareVideo } from "@/lib/public-share-video"; + +const video: PublicShareVideo = { + id: Video.VideoId.make("abc123"), + name: "Cap walkthrough", + ownerId: "owner123", + ownerName: "Richie", + sourceType: "desktopMP4", + width: 1920, + height: 1080, + duration: 64.4, +}; + +describe("Cap oEmbed", () => { + it.each([ + ["https://cap.so/s/abc123", "abc123"], + ["https://www.cap.so/s/abc123?t=3", "abc123"], + ["https://cap.link/abc123", "abc123"], + ["https://www.cap.link/abc123?foo=bar", "abc123"], + ])("parses supported share URL %s", (url, expected) => { + expect(parseCapShareUrl(url)).toBe(expected); + }); + + it.each([ + "http://cap.so/s/abc123", + "https://example.com/s/abc123", + "https://cap.so/embed/abc123", + "https://cap.link/video/demo.mp4", + "https://cap.so/s/abc123/extra", + "not-a-url", + ])("rejects unsupported URL %s", (url) => { + expect(parseCapShareUrl(url)).toBeNull(); + }); + + it("returns a responsive video response with preserved dimensions", () => { + const result = buildOEmbedVideo({ + video, + webUrl: "https://cap.so", + maxWidth: "640", + maxHeight: "400", + }); + + expect(result).toMatchObject({ + version: "1.0", + type: "video", + provider_name: "Cap", + title: "Cap walkthrough", + author_name: "Richie", + cache_age: 300, + width: 640, + height: 360, + duration: 64, + }); + expect(result.html).toContain('src="https://cap.so/embed/abc123"'); + expect(result.html).toContain( + 'style="border:0;width:100%;aspect-ratio:640/360"', + ); + expect(result.thumbnail_url).toBeUndefined(); + }); + + it("never exceeds either requested dimension", () => { + const result = buildOEmbedVideo({ + video, + webUrl: "https://cap.so", + maxWidth: "1000", + maxHeight: "300", + }); + + expect(result.width).toBe(533); + expect(result.height).toBe(300); + expect(result.thumbnail_url).toBeUndefined(); + }); + + it("includes the full thumbnail when no smaller dimensions are requested", () => { + const result = buildOEmbedVideo({ + video, + webUrl: "https://cap.so", + }); + + expect(result.thumbnail_url).toBe( + "https://cap.so/api/video/og?videoId=abc123", + ); + expect(result.thumbnail_width).toBe(1200); + expect(result.thumbnail_height).toBe(630); + }); +}); diff --git a/apps/web/__tests__/unit/player-js-receiver.test.ts b/apps/web/__tests__/unit/player-js-receiver.test.ts new file mode 100644 index 00000000000..9fb7135bad3 --- /dev/null +++ b/apps/web/__tests__/unit/player-js-receiver.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPlayerJsReceiver } from "@/app/embed/[videoId]/_components/use-player-js-receiver"; + +describe("Player.js receiver", () => { + it("advertises supported methods and controls the HTML video", async () => { + const posted: Array<{ message: string; origin: string }> = []; + let messageHandler: ((event: MessageEvent) => void) | undefined; + const parent = { + postMessage: (message: string, origin: string) => { + posted.push({ message, origin }); + }, + }; + const playerWindow = { + document: { referrer: "https://embed.example/page" }, + location: new URL("https://cap.so/embed/abc123"), + parent, + addEventListener: ( + event: string, + handler: (event: MessageEvent) => void, + ) => { + if (event === "message") messageHandler = handler; + }, + removeEventListener: vi.fn(), + }; + const mediaHandlers = new Map void>(); + const video = { + paused: true, + muted: false, + volume: 1, + duration: 100, + currentTime: 0, + loop: false, + buffered: { + length: 1, + end: () => 50, + }, + error: null, + play: vi.fn(async () => undefined), + pause: vi.fn(), + addEventListener: (event: string, handler: () => void) => { + mediaHandlers.set(event, handler); + }, + removeEventListener: vi.fn(), + }; + + const dispose = createPlayerJsReceiver({ + playerWindow: playerWindow as unknown as Parameters< + typeof createPlayerJsReceiver + >[0]["playerWindow"], + video: video as unknown as HTMLVideoElement, + }); + + expect(JSON.parse(posted[0]?.message ?? "{}")).toMatchObject({ + context: "player.js", + event: "ready", + value: { + src: "https://cap.so/embed/abc123", + methods: expect.arrayContaining([ + "play", + "setCurrentTime", + "getDuration", + ]), + }, + }); + expect(posted[0]?.origin).toBe("https://embed.example"); + + messageHandler?.({ + source: parent, + origin: "https://embed.example", + data: JSON.stringify({ + context: "player.js", + method: "setCurrentTime", + value: 150, + }), + } as unknown as MessageEvent); + expect(video.currentTime).toBe(100); + + messageHandler?.({ + source: parent, + origin: "https://embed.example", + data: { + context: "player.js", + method: "setCurrentTime", + value: Number.NaN, + }, + } as unknown as MessageEvent); + expect(video.currentTime).toBe(100); + + messageHandler?.({ + source: parent, + origin: "https://embed.example", + data: JSON.stringify({ + context: "player.js", + method: "play", + }), + } as unknown as MessageEvent); + expect(video.play).toHaveBeenCalled(); + + messageHandler?.({ + source: parent, + origin: "https://embed.example", + data: JSON.stringify({ + context: "player.js", + method: "addEventListener", + value: "progress", + listener: "listener-1", + }), + } as unknown as MessageEvent); + mediaHandlers.get("progress")?.(); + expect(JSON.parse(posted.at(-1)?.message ?? "{}")).toMatchObject({ + event: "progress", + listener: "listener-1", + value: { percent: 0.5 }, + }); + + dispose(); + }); + + it("ignores commands from a different parent origin", () => { + let messageHandler: ((event: MessageEvent) => void) | undefined; + const parent = { postMessage: vi.fn() }; + const video = { + play: vi.fn(async () => undefined), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + buffered: { length: 0, end: vi.fn() }, + }; + createPlayerJsReceiver({ + playerWindow: { + document: { referrer: "https://trusted.example/page" }, + location: new URL("https://cap.so/embed/abc123"), + parent, + addEventListener: ( + _event: string, + handler: (event: MessageEvent) => void, + ) => { + messageHandler = handler; + }, + removeEventListener: vi.fn(), + } as unknown as Parameters< + typeof createPlayerJsReceiver + >[0]["playerWindow"], + video: video as unknown as HTMLVideoElement, + }); + + messageHandler?.({ + source: parent, + origin: "https://attacker.example", + data: JSON.stringify({ context: "player.js", method: "play" }), + } as unknown as MessageEvent); + expect(video.play).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/public-share-video.test.ts b/apps/web/__tests__/unit/public-share-video.test.ts new file mode 100644 index 00000000000..70c5c5cd4f3 --- /dev/null +++ b/apps/web/__tests__/unit/public-share-video.test.ts @@ -0,0 +1,65 @@ +import { Video } from "@cap/web-domain"; +import { describe, expect, it } from "vitest"; +import type { PublicShareVideoCandidate } from "@/lib/public-share-video"; +import { isPublicShareVideoCandidateEligible } from "@/lib/public-share-video"; + +const candidate = ( + overrides: Partial = {}, +): PublicShareVideoCandidate => ({ + id: Video.VideoId.make("video123"), + name: "Demo", + ownerId: "owner123", + ownerName: "Richie", + public: true, + hasPassword: false, + hasInheritedPassword: false, + allowedEmailDomain: null, + isScreenshot: false, + hasActiveUpload: false, + sourceType: "desktopMP4", + jobStatus: "COMPLETE", + skipProcessing: false, + width: 1920, + height: 1080, + duration: 64, + ...overrides, +}); + +describe("public share video eligibility", () => { + it("accepts a completed unrestricted public recording", () => { + expect(isPublicShareVideoCandidateEligible(candidate())).toBe(true); + }); + + it.each([ + { public: false }, + { hasPassword: true }, + { hasInheritedPassword: true }, + { allowedEmailDomain: "@cap.so" }, + { isScreenshot: true }, + { hasActiveUpload: true }, + ])("rejects restricted or non-video candidates", (override) => { + expect(isPublicShareVideoCandidateEligible(candidate(override))).toBe( + false, + ); + }); + + it("requires MediaConvert playback to be complete or explicitly skipped", () => { + expect( + isPublicShareVideoCandidateEligible( + candidate({ + sourceType: "MediaConvert", + jobStatus: "PROCESSING", + }), + ), + ).toBe(false); + expect( + isPublicShareVideoCandidateEligible( + candidate({ + sourceType: "MediaConvert", + jobStatus: "PROCESSING", + skipProcessing: true, + }), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/__tests__/unit/share-video-metadata.test.ts b/apps/web/__tests__/unit/share-video-metadata.test.ts new file mode 100644 index 00000000000..6ea009d67c9 --- /dev/null +++ b/apps/web/__tests__/unit/share-video-metadata.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + buildShareVideoMetadata, + getShareVideoUrls, +} from "@/lib/share-video-metadata"; + +describe("share video metadata", () => { + it("generates valid canonical player, stream, and oEmbed URLs", () => { + const urls = getShareVideoUrls({ + videoId: "video123", + sourceType: "desktopMP4", + webUrl: "https://cap.so", + }); + + expect(urls.shareUrl).toBe("https://cap.so/s/video123"); + expect(urls.playerUrl).toBe("https://cap.so/embed/video123"); + expect(urls.streamUrl).toBe( + "https://cap.so/api/playlist?videoId=video123&videoType=mp4", + ); + expect(urls.oEmbedUrl).toContain( + "/api/oembed?url=https%3A%2F%2Fcap.so%2Fs%2Fvideo123&format=json", + ); + }); + + it("emits video metadata used by Open Graph and Twitter players", () => { + const metadata = buildShareVideoMetadata({ + videoId: "video123", + name: "Product demo", + sourceType: "desktopMP4", + webUrl: "https://cap.so", + }); + + expect(metadata.openGraph).toMatchObject({ + type: "video.other", + url: "https://cap.so/s/video123", + siteName: "Cap", + }); + const videos = + metadata.openGraph && "videos" in metadata.openGraph + ? metadata.openGraph.videos + : undefined; + expect(Array.isArray(videos) ? videos[0] : videos).toMatchObject({ + url: "https://cap.so/api/playlist?videoId=video123&videoType=mp4", + secureUrl: "https://cap.so/api/playlist?videoId=video123&videoType=mp4", + type: "video/mp4", + }); + expect(metadata.twitter).toMatchObject({ + card: "player", + players: { + playerUrl: "https://cap.so/embed/video123", + streamUrl: "https://cap.so/api/playlist?videoId=video123&videoType=mp4", + }, + }); + expect(metadata.alternates?.types?.["application/json+oembed"]).toEqual([ + { + title: "Product demo | Cap Recording", + url: expect.any(String), + }, + ]); + expect(metadata.other).toEqual({ + "twitter:player:stream:content_type": "video/mp4", + }); + }); + + it.each([ + ["MediaConvert", "master"], + ["local", "master"], + ["desktopSegments", "segments-master"], + ] as const)( + "emits a playable HLS URL for %s recordings", + (sourceType, type) => { + const urls = getShareVideoUrls({ + videoId: "video123", + sourceType, + webUrl: "https://cap.so", + }); + + expect(urls.streamUrl).toContain(`videoType=${type}`); + expect(urls.streamContentType).toBe("application/vnd.apple.mpegurl"); + if (sourceType === "desktopSegments") { + expect(urls.streamUrl).toContain("requireComplete=1"); + } + }, + ); +}); diff --git a/apps/web/__tests__/unit/slack-app-manifest.test.ts b/apps/web/__tests__/unit/slack-app-manifest.test.ts new file mode 100644 index 00000000000..2d3a17e61ee --- /dev/null +++ b/apps/web/__tests__/unit/slack-app-manifest.test.ts @@ -0,0 +1,68 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +type SlackAppManifest = { + display_information: { + description: string; + background_color: string; + }; + features: { + unfurl_domains: string[]; + }; + oauth_config: { + redirect_urls: string[]; + scopes: { + bot: string[]; + }; + }; + settings: { + event_subscriptions: { + request_url: string; + bot_events: string[]; + }; + }; +}; + +describe("Slack app manifest", () => { + it("declares the complete and minimal video unfurl contract", () => { + const manifest = JSON.parse( + readFileSync(join(process.cwd(), "slack-app-manifest.json"), "utf8"), + ) as SlackAppManifest; + + expect(manifest.display_information).toMatchObject({ + description: "Use Cap directly inside Slack.", + background_color: "#4785FF", + }); + expect(manifest.features.unfurl_domains).toEqual(["cap.so", "cap.link"]); + expect(manifest.oauth_config.scopes.bot).toEqual([ + "links:read", + "links:write", + "links.embed:write", + ]); + expect(manifest.oauth_config.redirect_urls).toEqual([ + "https://cap.so/api/integrations/slack/callback", + ]); + expect(manifest.settings.event_subscriptions).toEqual({ + request_url: "https://cap.so/api/integrations/slack/events", + bot_events: ["link_shared", "app_uninstalled"], + }); + }); + + it("removes encrypted Slack credentials before deleting an organization", () => { + const source = readFileSync( + join( + process.cwd(), + "../../packages/web-backend/src/Organisations/index.ts", + ), + "utf8", + ); + const integrationDelete = source.indexOf( + ".delete(Db.integrationInstallations)", + ); + const organizationDelete = source.indexOf(".delete(Db.organizations)"); + + expect(integrationDelete).toBeGreaterThan(-1); + expect(organizationDelete).toBeGreaterThan(integrationDelete); + }); +}); diff --git a/apps/web/__tests__/unit/slack-client.test.ts b/apps/web/__tests__/unit/slack-client.test.ts new file mode 100644 index 00000000000..32c6fe9486f --- /dev/null +++ b/apps/web/__tests__/unit/slack-client.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildSlackInstallUrl, + exchangeSlackOAuthCode, + SLACK_BOT_SCOPES, + type SlackUnfurlError, + sendSlackUnfurl, +} from "@/lib/slack/client"; + +const config = { + clientId: "client-id", + clientSecret: "client-secret", + signingSecret: "signing-secret", + redirectUrl: "https://cap.so/api/integrations/slack/callback", +}; + +const slackResponse = (scope = SLACK_BOT_SCOPES.join(",")) => + new Response( + JSON.stringify({ + ok: true, + access_token: "xoxb-token", + scope, + bot_user_id: "B123", + team: { + id: "T123", + name: "Cap", + }, + enterprise: null, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + +describe("Slack API client", () => { + it("builds an install URL with only the required bot scopes", () => { + const url = new URL( + buildSlackInstallUrl({ config, state: "signed-state" }), + ); + + expect(url.origin).toBe("https://slack.com"); + expect(url.pathname).toBe("/oauth/v2/authorize"); + expect(url.searchParams.get("client_id")).toBe("client-id"); + expect(url.searchParams.get("scope")).toBe(SLACK_BOT_SCOPES.join(",")); + expect(url.searchParams.get("redirect_uri")).toBe(config.redirectUrl); + expect(url.searchParams.get("state")).toBe("signed-state"); + }); + + it("accepts a complete OAuth response and preserves the workspace identity", async () => { + const fetchImpl = vi.fn(async () => slackResponse()); + const result = await exchangeSlackOAuthCode({ + code: "oauth-code", + config, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result).toEqual({ + accessToken: "xoxb-token", + scope: SLACK_BOT_SCOPES.join(","), + botUserId: "B123", + teamId: "T123", + teamName: "Cap", + enterpriseId: null, + }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("rejects OAuth responses that omit a required unfurl scope", async () => { + const fetchImpl = vi.fn(async () => + slackResponse("links:read,links:write"), + ); + + await expect( + exchangeSlackOAuthCode({ + code: "oauth-code", + config, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toThrow("missing required unfurl scopes"); + }); + + it("retries transient unfurl failures with the stable event locator", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: false, error: "ratelimited" }), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": "1", + }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const sleepImpl = vi.fn(async () => undefined); + + await sendSlackUnfurl({ + token: "xoxb-token", + event: { + channel: "C123", + messageTs: "123.456", + unfurlId: "U123", + source: "conversations_history", + }, + unfurls: { + "https://cap.so/s/abc123": { + blocks: [{ type: "video" }], + }, + }, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleepImpl, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(sleepImpl).toHaveBeenCalledWith(1000); + const request = fetchImpl.mock.calls[1]?.[1] as RequestInit; + expect(JSON.parse(String(request.body))).toMatchObject({ + unfurl_id: "U123", + source: "conversations_history", + }); + }); + + it("marks exhausted transient failures for durable recovery", async () => { + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ ok: false, error: "server_error" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect( + sendSlackUnfurl({ + token: "xoxb-token", + event: { + channel: "C123", + messageTs: "123.456", + }, + unfurls: { + "https://cap.so/s/abc123": { + blocks: [{ type: "video" }], + }, + }, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleepImpl: vi.fn(async () => undefined), + }), + ).rejects.toMatchObject({ + name: "SlackUnfurlError", + retryable: true, + } satisfies Partial); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it("marks permanent Slack failures as non-retryable", async () => { + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ ok: false, error: "invalid_auth" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect( + sendSlackUnfurl({ + token: "xoxb-token", + event: { + channel: "C123", + messageTs: "123.456", + }, + unfurls: { + "https://cap.so/s/abc123": { + blocks: [{ type: "video" }], + }, + }, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleepImpl: vi.fn(async () => undefined), + }), + ).rejects.toMatchObject({ + name: "SlackUnfurlError", + retryable: false, + } satisfies Partial); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/__tests__/unit/slack-events-route.test.ts b/apps/web/__tests__/unit/slack-events-route.test.ts new file mode 100644 index 00000000000..23cce070f80 --- /dev/null +++ b/apps/web/__tests__/unit/slack-events-route.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("Slack Events route contract", () => { + it("verifies the raw request and durably queues work before acknowledging", () => { + const route = readFileSync( + join(process.cwd(), "app/api/integrations/slack/events/route.ts"), + "utf8", + ); + const workflow = readFileSync( + join(process.cwd(), "workflows/slack-event.ts"), + "utf8", + ); + const bodyRead = route.indexOf("yield* request.text"); + const signatureCheck = route.indexOf("verifySlackSignature({", bodyRead); + const jsonParse = route.indexOf("JSON.parse(body)", signatureCheck); + const queuedWork = route.indexOf("start(slackEventWorkflow", jsonParse); + const acknowledgement = route.indexOf( + "return jsonResponse({ ok: true })", + queuedWork, + ); + + expect(bodyRead).toBeGreaterThan(-1); + expect(signatureCheck).toBeGreaterThan(bodyRead); + expect(jsonParse).toBeGreaterThan(signatureCheck); + expect(queuedWork).toBeGreaterThan(jsonParse); + expect(acknowledgement).toBeGreaterThan(queuedWork); + expect(route).toContain("HttpServerRequest.withMaxBodySize"); + expect(route).toContain( + 'return jsonResponse({ error: "Could not queue Slack event" }, 503)', + ); + expect(route).not.toContain("waitUntil("); + expect(workflow).toContain('"use workflow"'); + expect(workflow).toContain('"use step"'); + expect(workflow).toContain("processSlackEvent({"); + expect(workflow).toContain("while (!processed)"); + expect(workflow).toContain('await sleep("5m")'); + expect(workflow).toContain("processSlackEventStep.maxRetries = 5"); + }); +}); diff --git a/apps/web/__tests__/unit/slack-install-route.test.ts b/apps/web/__tests__/unit/slack-install-route.test.ts new file mode 100644 index 00000000000..70409159797 --- /dev/null +++ b/apps/web/__tests__/unit/slack-install-route.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("Slack install route contract", () => { + it("redirects unauthenticated users through the canonical login page", () => { + const route = readFileSync( + join(process.cwd(), "app/api/integrations/slack/install/route.ts"), + "utf8", + ); + + expect(route).toContain('new URL("/login", serverEnv().WEB_URL)'); + expect(route).not.toContain('new URL("/auth/signin"'); + }); +}); diff --git a/apps/web/__tests__/unit/slack-oauth-state.test.ts b/apps/web/__tests__/unit/slack-oauth-state.test.ts new file mode 100644 index 00000000000..a3977a327e0 --- /dev/null +++ b/apps/web/__tests__/unit/slack-oauth-state.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + createSlackOAuthState, + verifySlackOAuthState, +} from "@/lib/slack/oauth-state"; + +describe("Slack OAuth state", () => { + const secret = "client-secret"; + const now = 1720000000000; + + it("round-trips a signed, time-bounded state", () => { + const state = createSlackOAuthState({ + organizationId: "org123", + userId: "user123", + secret, + now, + nonce: "nonce123", + }); + + expect(verifySlackOAuthState({ state, secret, now })).toMatchObject({ + v: 1, + organizationId: "org123", + userId: "user123", + issuedAt: 1720000000, + nonce: "nonce123", + }); + }); + + it("rejects tampering, expiry, and states from the future", () => { + const state = createSlackOAuthState({ + organizationId: "org123", + userId: "user123", + secret, + now, + nonce: "nonce123", + }); + expect( + verifySlackOAuthState({ + state: `${state}x`, + secret, + now, + }), + ).toBeNull(); + expect( + verifySlackOAuthState({ + state, + secret, + now: now + 601_000, + }), + ).toBeNull(); + expect( + verifySlackOAuthState({ + state, + secret, + now: now - 61_000, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/__tests__/unit/slack-signature.test.ts b/apps/web/__tests__/unit/slack-signature.test.ts new file mode 100644 index 00000000000..6f258043760 --- /dev/null +++ b/apps/web/__tests__/unit/slack-signature.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + createSlackSignature, + verifySlackSignature, +} from "@/lib/slack/signature"; + +describe("Slack request signatures", () => { + const signingSecret = "test-signing-secret"; + const timestamp = "1720000000"; + const now = Number(timestamp) * 1000; + const body = '{"type":"event_callback"}'; + + it("accepts a current signature over the exact raw body", () => { + const signature = createSlackSignature({ + body, + timestamp, + signingSecret, + }); + expect( + verifySlackSignature({ + body, + timestamp, + signature, + signingSecret, + now, + }), + ).toBe(true); + }); + + it("rejects tampering, missing headers, and stale requests", () => { + const signature = createSlackSignature({ + body, + timestamp, + signingSecret, + }); + expect( + verifySlackSignature({ + body: `${body} `, + timestamp, + signature, + signingSecret, + now, + }), + ).toBe(false); + expect( + verifySlackSignature({ + body, + timestamp: undefined, + signature, + signingSecret, + now, + }), + ).toBe(false); + expect( + verifySlackSignature({ + body, + timestamp, + signature, + signingSecret, + now: now + 301_000, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/__tests__/unit/slack-unfurl.test.ts b/apps/web/__tests__/unit/slack-unfurl.test.ts new file mode 100644 index 00000000000..510cb42565a --- /dev/null +++ b/apps/web/__tests__/unit/slack-unfurl.test.ts @@ -0,0 +1,200 @@ +import { Video } from "@cap/web-domain"; +import { describe, expect, it, vi } from "vitest"; +import type { PublicShareVideo } from "@/lib/public-share-video"; +import { + buildSlackVideoBlock, + parseSlackEventPayload, + processSlackEvent, +} from "@/lib/slack/unfurl"; + +const video: PublicShareVideo = { + id: Video.VideoId.make("abc123"), + name: "Cap walkthrough", + ownerId: "owner123", + ownerName: "Richie", + sourceType: "desktopMP4", + width: 1920, + height: 1080, + duration: 64, +}; + +const payload = { + type: "event_callback" as const, + team_id: "T123", + event_id: "E123", + event: { + type: "link_shared" as const, + channel: "C123", + message_ts: "123.456", + unfurl_id: "U123", + source: "conversations_history", + links: [ + { + domain: "cap.so", + url: "https://cap.so/s/abc123", + }, + ], + }, +}; + +describe("Slack video unfurls", () => { + it("parses link and uninstall events without accepting malformed payloads", () => { + expect(parseSlackEventPayload(payload)).toEqual(payload); + expect( + parseSlackEventPayload({ + type: "event_callback", + team_id: "T123", + event: { type: "app_uninstalled" }, + }), + ).toMatchObject({ + type: "event_callback", + team_id: "T123", + event: { type: "app_uninstalled" }, + }); + expect(parseSlackEventPayload({ type: "event_callback" })).toBeNull(); + }); + + it("builds a Slack-compliant navigation-free video block URL", () => { + expect( + buildSlackVideoBlock({ + video, + shareUrl: "https://cap.so/s/abc123", + webUrl: "https://cap.so", + }), + ).toMatchObject({ + type: "video", + title_url: "https://cap.so/s/abc123", + video_url: "https://cap.so/embed/abc123?autoplay=true&slack=true", + thumbnail_url: "https://cap.so/api/video/og?videoId=abc123", + provider_name: "Cap", + author_name: "Richie", + }); + }); + + it("keeps video block text within Slack's strict limits", () => { + const block = buildSlackVideoBlock({ + video: { + ...video, + name: "V".repeat(250), + ownerName: "A".repeat(80), + }, + shareUrl: "https://cap.so/s/abc123", + webUrl: "https://cap.so", + }); + + expect(block.title.text).toHaveLength(199); + expect(block.author_name).toHaveLength(49); + }); + + it("publishes only eligible links using the event unfurl locator", async () => { + const sendUnfurl = vi.fn(async () => undefined); + await processSlackEvent({ + payload, + webUrl: "https://cap.so", + dependencies: { + deleteInstallation: vi.fn(async () => undefined), + getInstallationToken: vi.fn(async () => "xoxb-token"), + getVideo: vi.fn(async () => video), + sendUnfurl, + }, + }); + + expect(sendUnfurl).toHaveBeenCalledWith({ + token: "xoxb-token", + event: { + channel: "C123", + messageTs: "123.456", + unfurlId: "U123", + source: "conversations_history", + }, + unfurls: { + "https://cap.so/s/abc123": { + blocks: [expect.objectContaining({ type: "video" })], + }, + }, + }); + }); + + it("uses the canonical Cap recording URL for legacy cap.link unfurls", async () => { + const sendUnfurl = vi.fn(async () => undefined); + await processSlackEvent({ + payload: { + ...payload, + event: { + ...payload.event, + links: [ + { + domain: "cap.link", + url: "https://cap.link/abc123", + }, + ], + }, + }, + webUrl: "https://cap.so", + dependencies: { + deleteInstallation: vi.fn(async () => undefined), + getInstallationToken: vi.fn(async () => "xoxb-token"), + getVideo: vi.fn(async () => video), + sendUnfurl, + }, + }); + + expect(sendUnfurl).toHaveBeenCalledWith( + expect.objectContaining({ + unfurls: { + "https://cap.link/abc123": { + blocks: [ + expect.objectContaining({ + title_url: "https://cap.so/s/abc123", + }), + ], + }, + }, + }), + ); + }); + + it("degrades silently when a link is restricted or the app is not installed", async () => { + const sendUnfurl = vi.fn(async () => undefined); + await processSlackEvent({ + payload, + webUrl: "https://cap.so", + dependencies: { + deleteInstallation: vi.fn(async () => undefined), + getInstallationToken: vi.fn(async () => "xoxb-token"), + getVideo: vi.fn(async () => null), + sendUnfurl, + }, + }); + await processSlackEvent({ + payload, + webUrl: "https://cap.so", + dependencies: { + deleteInstallation: vi.fn(async () => undefined), + getInstallationToken: vi.fn(async () => null), + getVideo: vi.fn(async () => video), + sendUnfurl, + }, + }); + expect(sendUnfurl).not.toHaveBeenCalled(); + }); + + it("removes credentials when Slack reports an uninstall", async () => { + const deleteInstallation = vi.fn(async () => undefined); + await processSlackEvent({ + payload: { + type: "event_callback", + team_id: "T123", + event: { type: "app_uninstalled" }, + }, + webUrl: "https://cap.so", + dependencies: { + deleteInstallation, + getInstallationToken: vi.fn(async () => null), + getVideo: vi.fn(async () => null), + sendUnfurl: vi.fn(async () => undefined), + }, + }); + expect(deleteInstallation).toHaveBeenCalledWith("T123"); + }); +}); diff --git a/apps/web/actions/organization/slack.ts b/apps/web/actions/organization/slack.ts new file mode 100644 index 00000000000..5dc272a2a97 --- /dev/null +++ b/apps/web/actions/organization/slack.ts @@ -0,0 +1,44 @@ +"use server"; + +import { getCurrentUser } from "@cap/database/auth/session"; +import type { Organisation } from "@cap/web-domain"; +import { revalidatePath } from "next/cache"; +import { isSlackIntegrationConfigured } from "@/lib/slack/client"; +import { + deleteSlackInstallation, + listSlackInstallations, +} from "@/lib/slack/installations"; +import { requireOrganizationSettingsManager } from "./authorization"; + +const settingsPath = "/dashboard/settings/organization/integrations"; + +const requireSlackSettingsManager = async ( + organizationId: Organisation.OrganisationId, +) => { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + await requireOrganizationSettingsManager(user.id, organizationId); + return user; +}; + +export const getOrganizationSlackSettings = async ( + organizationId: Organisation.OrganisationId, +) => { + await requireSlackSettingsManager(organizationId); + return { + configured: isSlackIntegrationConfigured(), + installations: await listSlackInstallations(organizationId), + }; +}; + +export const disconnectOrganizationSlack = async ({ + organizationId, + installationId, +}: { + organizationId: Organisation.OrganisationId; + installationId: string; +}) => { + await requireSlackSettingsManager(organizationId); + await deleteSlackInstallation({ organizationId, installationId }); + revalidatePath(settingsPath); +}; diff --git a/apps/web/app/(org)/dashboard/settings/organization/integrations/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/integrations/page.tsx index 2793e9b2414..b59ef49f9e9 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/integrations/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/integrations/page.tsx @@ -1,14 +1,20 @@ import { getCurrentUser } from "@cap/database/auth/session"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { getOrganizationSlackSettings } from "@/actions/organization/slack"; import { getOrganizationStorageSettings } from "@/actions/organization/storage"; +import { SlackIntegration } from "./slack-integration"; import { OrganizationStorageIntegrations } from "./storage-integrations"; export const metadata: Metadata = { title: "Organization Integrations — Cap", }; -export default async function OrganizationIntegrationsPage() { +export default async function OrganizationIntegrationsPage({ + searchParams, +}: { + searchParams: Promise<{ slack?: string }>; +}) { const user = await getCurrentUser(); if (!user) { @@ -33,6 +39,20 @@ export default async function OrganizationIntegrationsPage() { throw error; }); + const slackSettings = await getOrganizationSlackSettings( + user.activeOrganizationId, + ); + const { slack } = await searchParams; - return ; + return ( +
+ + +
+ ); } diff --git a/apps/web/app/(org)/dashboard/settings/organization/integrations/slack-integration.tsx b/apps/web/app/(org)/dashboard/settings/organization/integrations/slack-integration.tsx new file mode 100644 index 00000000000..cd86cf0dc74 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/integrations/slack-integration.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Button } from "@cap/ui"; +import type { Organisation } from "@cap/web-domain"; +import { MessageSquareIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useTransition } from "react"; +import { toast } from "sonner"; +import { disconnectOrganizationSlack } from "@/actions/organization/slack"; + +type SlackInstallation = { + id: string; + teamId: string; + teamName: string; +}; + +const resultMessages: Record< + string, + { type: "success" | "error"; message: string } +> = { + connected: { + type: "success", + message: "Slack workspace connected", + }, + cancelled: { + type: "error", + message: "Slack connection was cancelled", + }, + failed: { + type: "error", + message: "Slack connection failed", + }, + forbidden: { + type: "error", + message: "Only organization admins can connect Slack", + }, + invalid: { + type: "error", + message: "Slack connection expired or was invalid", + }, + "not-configured": { + type: "error", + message: "Slack is not configured on this Cap deployment", + }, +}; + +export function SlackIntegration({ + organizationId, + configured, + installations, + result, +}: { + organizationId: Organisation.OrganisationId; + configured: boolean; + installations: SlackInstallation[]; + result?: string; +}) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const connected = configured && installations.length > 0; + + useEffect(() => { + if (!result) return; + const notification = resultMessages[result]; + if (notification?.type === "success") { + toast.success(notification.message); + } else if (notification) { + toast.error(notification.message); + } + router.replace("/dashboard/settings/organization/integrations"); + }, [result, router]); + + const disconnect = (installation: SlackInstallation) => { + if ( + !window.confirm( + `Disconnect ${installation.teamName} from Cap link previews?`, + ) + ) { + return; + } + startTransition(async () => { + try { + await disconnectOrganizationSlack({ + organizationId, + installationId: installation.id, + }); + toast.success(`${installation.teamName} disconnected`); + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to disconnect Slack", + ); + } + }); + }; + + return ( +
+
+ +
+

Slack

+

+ Play public Cap recordings directly inside Slack. +

+
+ + {connected + ? "Connected" + : configured + ? "Not connected" + : "Not configured"} + +
+ +
+ {installations.length > 0 && ( +
+ {installations.map((installation) => ( +
+
+

+ {installation.teamName} +

+

+ {installation.teamId} +

+
+ +
+ ))} +
+ )} + +
+

+ {configured + ? "Install Cap in each Slack workspace where links should open as inline players." + : "Add the Slack app credentials and database encryption key to this deployment before connecting a workspace."} +

+ {configured ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/app/api/integrations/slack/callback/route.ts b/apps/web/app/api/integrations/slack/callback/route.ts new file mode 100644 index 00000000000..5dee6bc8b7f --- /dev/null +++ b/apps/web/app/api/integrations/slack/callback/route.ts @@ -0,0 +1,101 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import { serverEnv } from "@cap/env"; +import { Organisation } from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpServerResponse, +} from "@effect/platform"; +import { Effect, Layer, Schema } from "effect"; +import { requireOrganizationSettingsManager } from "@/actions/organization/authorization"; +import { apiToHandler } from "@/lib/server"; +import { + exchangeSlackOAuthCode, + getSlackConfig, + isSlackIntegrationConfigured, +} from "@/lib/slack/client"; +import { saveSlackInstallation } from "@/lib/slack/installations"; +import { verifySlackOAuthState } from "@/lib/slack/oauth-state"; + +const CallbackParams = Schema.Struct({ + code: Schema.optional(Schema.String), + state: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}); + +class Api extends HttpApi.make("CapSlackCallbackApi").add( + HttpApiGroup.make("root").add( + HttpApiEndpoint.get( + "completeSlackInstall", + )`/api/integrations/slack/callback`.setUrlParams(CallbackParams), + ), +) {} + +const settingsPath = "/dashboard/settings/organization/integrations"; +const redirectToSettings = (result: string) => { + const url = new URL(settingsPath, serverEnv().WEB_URL); + url.searchParams.set("slack", result); + return HttpServerResponse.redirect(url, { status: 302 }); +}; + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + handlers.handle("completeSlackInstall", ({ urlParams }) => + Effect.tryPromise(async () => { + if (urlParams.error) return redirectToSettings("cancelled"); + const config = getSlackConfig(); + if ( + !config || + !isSlackIntegrationConfigured() || + !urlParams.code || + !urlParams.state + ) { + return redirectToSettings("invalid"); + } + const state = verifySlackOAuthState({ + state: urlParams.state, + secret: config.clientSecret, + }); + const user = await getCurrentUser(); + if (!state || !user || state.userId !== user.id) { + return redirectToSettings("invalid"); + } + const organizationId = Organisation.OrganisationId.make( + state.organizationId, + ); + await requireOrganizationSettingsManager(user.id, organizationId); + const result = await exchangeSlackOAuthCode({ + code: urlParams.code, + config, + }); + await saveSlackInstallation({ + organizationId, + installedByUserId: user.id, + teamId: result.teamId, + teamName: result.teamName, + enterpriseId: result.enterpriseId, + botUserId: result.botUserId, + accessToken: result.accessToken, + scope: result.scope, + }); + return redirectToSettings("connected"); + }).pipe( + Effect.catchAll((error) => { + console.error( + "[slack-oauth] Installation failed", + error instanceof Error ? error.message : "Unknown error", + ); + return Effect.succeed(redirectToSettings("failed")); + }), + ), + ), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const GET = handler; diff --git a/apps/web/app/api/integrations/slack/events/route.ts b/apps/web/app/api/integrations/slack/events/route.ts new file mode 100644 index 00000000000..4a3e480c502 --- /dev/null +++ b/apps/web/app/api/integrations/slack/events/route.ts @@ -0,0 +1,101 @@ +import { serverEnv } from "@cap/env"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect, Layer, Option } from "effect"; +import { start } from "workflow/api"; +import { apiToHandler } from "@/lib/server"; +import { getSlackConfig } from "@/lib/slack/client"; +import { verifySlackSignature } from "@/lib/slack/signature"; +import { parseSlackEventPayload } from "@/lib/slack/unfurl"; +import { slackEventWorkflow } from "@/workflows/slack-event"; + +const MAX_BODY_BYTES = 1_000_000; + +class Api extends HttpApi.make("CapSlackEventsApi").add( + HttpApiGroup.make("root").add( + HttpApiEndpoint.post("receiveSlackEvent")`/api/integrations/slack/events`, + ), +) {} + +const jsonResponse = (body: unknown, status = 200) => + HttpServerResponse.text(JSON.stringify(body), { + status, + contentType: "application/json; charset=utf-8", + headers: { + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + }, + }); + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + handlers.handle("receiveSlackEvent", () => + Effect.gen(function* () { + const config = getSlackConfig(); + if (!config) { + return jsonResponse({ error: "Slack is not configured" }, 503); + } + const request = yield* HttpServerRequest.HttpServerRequest; + const body = yield* request.text.pipe( + HttpServerRequest.withMaxBodySize(Option.some(MAX_BODY_BYTES)), + Effect.catchAll(() => Effect.succeed("")), + ); + if ( + !verifySlackSignature({ + body, + timestamp: request.headers["x-slack-request-timestamp"], + signature: request.headers["x-slack-signature"], + signingSecret: config.signingSecret, + }) + ) { + return jsonResponse({ error: "Invalid signature" }, 401); + } + + let value: unknown; + try { + value = JSON.parse(body); + } catch { + return jsonResponse({ error: "Invalid request body" }, 400); + } + const payload = parseSlackEventPayload(value); + if (!payload) { + return jsonResponse({ error: "Unsupported Slack event" }, 400); + } + if (payload.type === "url_verification") { + return jsonResponse({ challenge: payload.challenge }); + } + + const queued = yield* Effect.tryPromise(() => + start(slackEventWorkflow, [payload, serverEnv().WEB_URL]), + ).pipe( + Effect.as(true), + Effect.tapError((error) => + Effect.sync(() => { + console.error( + "[slack-unfurl] Failed to queue event", + error instanceof Error ? error.message : "Unknown error", + ); + }), + ), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!queued) { + return jsonResponse({ error: "Could not queue Slack event" }, 503); + } + return jsonResponse({ ok: true }); + }), + ), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const POST = handler; diff --git a/apps/web/app/api/integrations/slack/install/route.ts b/apps/web/app/api/integrations/slack/install/route.ts new file mode 100644 index 00000000000..25f57bf568d --- /dev/null +++ b/apps/web/app/api/integrations/slack/install/route.ts @@ -0,0 +1,80 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import { serverEnv } from "@cap/env"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpServerResponse, +} from "@effect/platform"; +import { Effect, Layer } from "effect"; +import { requireOrganizationSettingsManager } from "@/actions/organization/authorization"; +import { apiToHandler } from "@/lib/server"; +import { + buildSlackInstallUrl, + getSlackConfig, + isSlackIntegrationConfigured, +} from "@/lib/slack/client"; +import { createSlackOAuthState } from "@/lib/slack/oauth-state"; + +class Api extends HttpApi.make("CapSlackInstallApi").add( + HttpApiGroup.make("root").add( + HttpApiEndpoint.get("installSlack")`/api/integrations/slack/install`, + ), +) {} + +const settingsPath = "/dashboard/settings/organization/integrations"; + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + handlers.handle("installSlack", () => + Effect.gen(function* () { + const user = yield* Effect.tryPromise(getCurrentUser); + if (!user) { + const signInUrl = new URL("/login", serverEnv().WEB_URL); + signInUrl.searchParams.set("next", settingsPath); + return HttpServerResponse.redirect(signInUrl, { status: 302 }); + } + const config = getSlackConfig(); + if ( + !config || + !isSlackIntegrationConfigured() || + !user.activeOrganizationId + ) { + const url = new URL(settingsPath, serverEnv().WEB_URL); + url.searchParams.set("slack", "not-configured"); + return HttpServerResponse.redirect(url, { status: 302 }); + } + yield* Effect.tryPromise(() => + requireOrganizationSettingsManager( + user.id, + user.activeOrganizationId, + ), + ); + const state = createSlackOAuthState({ + organizationId: user.activeOrganizationId, + userId: user.id, + secret: config.clientSecret, + }); + return HttpServerResponse.redirect( + buildSlackInstallUrl({ config, state }), + { status: 302 }, + ); + }).pipe( + Effect.catchAll(() => { + const url = new URL(settingsPath, serverEnv().WEB_URL); + url.searchParams.set("slack", "forbidden"); + return Effect.succeed( + HttpServerResponse.redirect(url, { status: 302 }), + ); + }), + ), + ), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const GET = handler; diff --git a/apps/web/app/api/oembed/route.ts b/apps/web/app/api/oembed/route.ts new file mode 100644 index 00000000000..72a54bf5fb4 --- /dev/null +++ b/apps/web/app/api/oembed/route.ts @@ -0,0 +1,85 @@ +import { buildEnv } from "@cap/env"; +import { Video } from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpServerResponse, +} from "@effect/platform"; +import { Effect, Layer, Schema } from "effect"; +import { buildOEmbedVideo, parseCapShareUrl } from "@/lib/oembed"; +import { getPublicShareVideo } from "@/lib/public-share-video"; +import { apiToHandler } from "@/lib/server"; + +const GetOEmbedParams = Schema.Struct({ + url: Schema.String, + format: Schema.optional(Schema.String), + maxwidth: Schema.optional(Schema.String), + maxheight: Schema.optional(Schema.String), +}); + +class Api extends HttpApi.make("CapOEmbedApi").add( + HttpApiGroup.make("root").add( + HttpApiEndpoint.get("getOEmbed")`/api/oembed`.setUrlParams(GetOEmbedParams), + ), +) {} + +const jsonResponse = (body: unknown, status = 200) => + HttpServerResponse.text(JSON.stringify(body), { + status, + contentType: "application/json; charset=utf-8", + headers: { + "Access-Control-Allow-Origin": "*", + "Cache-Control": + status === 200 + ? "public, max-age=300, stale-while-revalidate=3600" + : "private, no-store", + "X-Content-Type-Options": "nosniff", + }, + }); + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + handlers.handle("getOEmbed", ({ urlParams }) => + Effect.gen(function* () { + if (urlParams.format && urlParams.format !== "json") { + return jsonResponse( + { error: "Only the json oEmbed format is supported" }, + 501, + ); + } + const videoId = parseCapShareUrl(urlParams.url); + if (!videoId) { + return jsonResponse({ error: "Invalid Cap share URL" }, 400); + } + const video = yield* Effect.tryPromise(() => + getPublicShareVideo(Video.VideoId.make(videoId)), + ); + if (!video) { + return jsonResponse({ error: "Video not found" }, 404); + } + return jsonResponse( + buildOEmbedVideo({ + video, + webUrl: buildEnv.NEXT_PUBLIC_WEB_URL, + maxWidth: urlParams.maxwidth, + maxHeight: urlParams.maxheight, + }), + ); + }).pipe( + Effect.catchAll(() => + Effect.succeed( + jsonResponse({ error: "Unable to load video" }, 500), + ), + ), + ), + ), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const GET = handler; diff --git a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx index 45f2e1762b5..b8dc8d0f440 100644 --- a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx +++ b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx @@ -27,6 +27,7 @@ import { parseVTT, type TranscriptEntry, } from "@/app/s/[videoId]/_components/utils/transcript-utils"; +import { usePlayerJsReceiver } from "./use-player-js-receiver"; declare global { interface Window { @@ -57,6 +58,7 @@ export const EmbedVideo = forwardRef< chapters?: { title: string; start: number }[]; ownerName?: string | null; autoplay?: boolean; + minimal?: boolean; viewerSettings?: ViewerSettings | null; showPlaybackStatusBadge?: boolean; } @@ -68,14 +70,17 @@ export const EmbedVideo = forwardRef< comments: _comments, chapters = [], ownerName, - autoplay: _autoplay = false, + autoplay = false, + minimal = false, viewerSettings, showPlaybackStatusBadge = false, }, ref, ) => { const videoRef = useRef(null); + const playerContainerRef = useRef(null); useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement); + usePlayerJsReceiver({ playerContainerRef, videoRef }); const [transcriptData, setTranscriptData] = useState([]); const [longestDuration, setLongestDuration] = useState( @@ -227,7 +232,10 @@ export const EmbedVideo = forwardRef< return ( <> -
+
{isActivelyRecording ? ( setUserConfirmedStopped(true)} @@ -247,6 +255,7 @@ export const EmbedVideo = forwardRef< chaptersSrc={chaptersDisabled ? "" : chaptersUrl || ""} captionsSrc={captionsDisabled ? "" : subtitleUrl || ""} videoRef={videoRef} + autoplay={autoplay} enableCrossOrigin={enableCrossOrigin} hasActiveUpload={data.hasActiveUpload} /> @@ -260,80 +269,83 @@ export const EmbedVideo = forwardRef< chaptersSrc={chaptersDisabled ? "" : chaptersUrl || ""} captionsSrc={captionsDisabled ? "" : subtitleUrl || ""} videoRef={videoRef} + autoplay={autoplay} hasActiveUpload={data.hasActiveUpload} isLiveSegments={isSegmentsSource} /> )}
- - {!isPlaying && ( -
- -
- {ownerName && ( - - )} -
- e.stopPropagation()} - > -

- {data.name} -

-
-
- {ownerName && ( -

- {ownerName} -

- )} - {ownerName && longestDuration > 0 && ( - <> - -

- {formatTime(longestDuration)} + {!minimal && ( + + {!isPlaying && ( +

+ +
+ {ownerName && ( + + )} +
+ e.stopPropagation()} + > +

+ {data.name} +

+
+
+ {ownerName && ( +

+ {ownerName}

- - )} + )} + {ownerName && longestDuration > 0 && ( + <> + +

+ {formatTime(longestDuration)} +

+ + )} +
-
- - { - e.stopPropagation(); - window.open("https://cap.so", "_blank"); - }} - className="hidden z-10 gap-2 items-center px-3 py-2 text-sm rounded-full border backdrop-blur-sm transition-colors duration-200 sm:flex border-white/10 w-fit text-white/80 hover:text-white bg-black/50" - aria-label="Powered by Cap" - > - - Powered by - - - -
- )} - + + { + e.stopPropagation(); + window.open("https://cap.so", "_blank"); + }} + className="hidden z-10 gap-2 items-center px-3 py-2 text-sm rounded-full border backdrop-blur-sm transition-colors duration-200 sm:flex border-white/10 w-fit text-white/80 hover:text-white bg-black/50" + aria-label="Powered by Cap" + > + + Powered by + + + +
+ )} + + )} ); }, diff --git a/apps/web/app/embed/[videoId]/_components/use-player-js-receiver.ts b/apps/web/app/embed/[videoId]/_components/use-player-js-receiver.ts new file mode 100644 index 00000000000..13e18b72afa --- /dev/null +++ b/apps/web/app/embed/[videoId]/_components/use-player-js-receiver.ts @@ -0,0 +1,280 @@ +"use client"; + +import { type RefObject, useEffect } from "react"; + +const CONTEXT = "player.js"; +const VERSION = "0.0.11"; +const EVENTS = [ + "ready", + "play", + "pause", + "ended", + "timeupdate", + "progress", + "error", + "seeked", +] as const; +const METHODS = [ + "play", + "pause", + "getPaused", + "mute", + "unmute", + "getMuted", + "setVolume", + "getVolume", + "getDuration", + "setCurrentTime", + "getCurrentTime", + "setLoop", + "getLoop", + "removeEventListener", + "addEventListener", +] as const; + +type PlayerJsMessage = { + context?: unknown; + method?: unknown; + value?: unknown; + listener?: unknown; +}; + +type PlayerJsWindow = Pick< + Window, + "addEventListener" | "removeEventListener" | "location" | "parent" +> & { + document: Pick; +}; + +const parseMessage = (value: unknown): PlayerJsMessage | null => { + if (typeof value === "string") { + try { + const parsed: unknown = JSON.parse(value); + return parsed && typeof parsed === "object" + ? (parsed as PlayerJsMessage) + : null; + } catch { + return null; + } + } + return value && typeof value === "object" ? (value as PlayerJsMessage) : null; +}; + +const getProgress = (video: HTMLVideoElement) => { + if (!Number.isFinite(video.duration) || video.duration <= 0) return 0; + let buffered = 0; + for (let index = 0; index < video.buffered.length; index++) { + buffered = Math.max(buffered, video.buffered.end(index)); + } + return Math.min(1, buffered / video.duration); +}; + +export const createPlayerJsReceiver = ({ + playerWindow, + video, +}: { + playerWindow: PlayerJsWindow; + video: HTMLVideoElement; +}) => { + const referrerOrigin = (() => { + try { + return playerWindow.document.referrer + ? new URL(playerWindow.document.referrer).origin + : null; + } catch { + return null; + } + })(); + const listeners = new Map>(); + const send = (event: string, value?: unknown, listener?: string | null) => { + const payload: Record = { + context: CONTEXT, + version: VERSION, + event, + }; + if (value !== undefined) payload.value = value; + if (listener !== undefined && listener !== null) { + payload.listener = listener; + } + playerWindow.parent.postMessage( + JSON.stringify(payload), + referrerOrigin ?? "*", + ); + }; + const emit = (event: string, value?: unknown) => { + for (const listener of listeners.get(event) ?? []) { + send(event, value, listener); + } + }; + const readyValue = () => ({ + src: playerWindow.location.toString(), + events: [...EVENTS], + methods: [...METHODS], + }); + + const onMessage = (event: MessageEvent) => { + if ( + event.source !== playerWindow.parent || + (referrerOrigin !== null && event.origin !== referrerOrigin) + ) { + return; + } + const message = parseMessage(event.data); + if ( + message?.context !== CONTEXT || + typeof message.method !== "string" || + !METHODS.includes(message.method as (typeof METHODS)[number]) + ) { + return; + } + const listener = + typeof message.listener === "string" ? message.listener : null; + + if (message.method === "addEventListener") { + if (typeof message.value !== "string") return; + const eventListeners = listeners.get(message.value) ?? new Set(); + eventListeners.add(listener); + listeners.set(message.value, eventListeners); + if (message.value === "ready") { + send("ready", readyValue(), listener); + } + return; + } + if (message.method === "removeEventListener") { + if (typeof message.value !== "string") return; + const eventListeners = listeners.get(message.value); + if (!eventListeners) return; + eventListeners.delete(listener); + if (eventListeners.size === 0) listeners.delete(message.value); + return; + } + + switch (message.method) { + case "play": + void video.play().catch(() => { + emit("error", { + code: video.error?.code ?? 0, + msg: video.error?.message ?? "Playback was blocked", + }); + }); + break; + case "pause": + video.pause(); + break; + case "getPaused": + send("getPaused", video.paused, listener); + break; + case "mute": + video.muted = true; + break; + case "unmute": + video.muted = false; + break; + case "getMuted": + send("getMuted", video.muted, listener); + break; + case "setVolume": + if ( + typeof message.value === "number" && + Number.isFinite(message.value) + ) { + video.volume = Math.max(0, Math.min(1, message.value / 100)); + } + break; + case "getVolume": + send("getVolume", video.volume * 100, listener); + break; + case "getDuration": + send("getDuration", video.duration, listener); + break; + case "setCurrentTime": + if ( + typeof message.value === "number" && + Number.isFinite(message.value) + ) { + const duration = Number.isFinite(video.duration) + ? video.duration + : message.value; + video.currentTime = Math.max(0, Math.min(duration, message.value)); + } + break; + case "getCurrentTime": + send("getCurrentTime", video.currentTime, listener); + break; + case "setLoop": + if (typeof message.value === "boolean") { + video.loop = message.value; + } + break; + case "getLoop": + send("getLoop", video.loop, listener); + break; + } + }; + + const eventHandlers = { + play: () => emit("play"), + pause: () => emit("pause"), + ended: () => emit("ended"), + timeupdate: () => + emit("timeupdate", { + seconds: video.currentTime, + duration: video.duration, + }), + progress: () => emit("progress", { percent: getProgress(video) }), + error: () => + emit("error", { + code: video.error?.code ?? 0, + msg: video.error?.message ?? "Playback failed", + }), + seeked: () => emit("seeked"), + }; + + playerWindow.addEventListener("message", onMessage); + for (const [event, handler] of Object.entries(eventHandlers)) { + video.addEventListener(event, handler); + } + send("ready", readyValue()); + + return () => { + playerWindow.removeEventListener("message", onMessage); + for (const [event, handler] of Object.entries(eventHandlers)) { + video.removeEventListener(event, handler); + } + listeners.clear(); + }; +}; + +export const usePlayerJsReceiver = ({ + playerContainerRef, + videoRef, +}: { + playerContainerRef: RefObject; + videoRef: RefObject; +}) => { + useEffect(() => { + const playerContainer = playerContainerRef.current; + if (!playerContainer) return; + let currentVideo: HTMLVideoElement | null = null; + let dispose: (() => void) | null = null; + const bind = () => { + if (videoRef.current === currentVideo) return; + dispose?.(); + currentVideo = videoRef.current; + dispose = currentVideo + ? createPlayerJsReceiver({ + playerWindow: window, + video: currentVideo, + }) + : null; + }; + const observer = new MutationObserver(bind); + observer.observe(playerContainer, { childList: true, subtree: true }); + bind(); + + return () => { + observer.disconnect(); + dispose?.(); + }; + }, [playerContainerRef, videoRef]); +}; diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx index 1aee3c782b7..e6bb947e850 100644 --- a/apps/web/app/embed/[videoId]/page.tsx +++ b/apps/web/app/embed/[videoId]/page.tsx @@ -25,6 +25,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import * as EffectRuntime from "@/lib/server"; +import { buildShareVideoMetadata } from "@/lib/share-video-metadata"; import { transcribeVideo } from "@/lib/transcribe"; import { isAiGenerationEnabled } from "@/utils/flags"; import { EmbedVideo } from "./_components/EmbedVideo"; @@ -41,54 +42,12 @@ export async function generateMetadata( Option.match({ onNone: () => notFound(), onSome: ([video]) => ({ - title: `${video.name} | Cap Recording`, - description: "Watch this video on Cap", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1200, - height: 630, - }, - ], - videos: [ - { - url: new URL( - `/api/playlist?userId=${video.ownerId}&videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1280, - height: 720, - type: "video/mp4", - }, - ], - }, - twitter: { - card: "player", - title: `${video.name} | Cap Recording`, - description: "Watch this video on Cap", - images: [ - new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - ], - players: { - playerUrl: new URL( - `/embed/${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - streamUrl: new URL( - `/api/playlist?userId=${video.ownerId}&videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1280, - height: 720, - }, - }, + ...buildShareVideoMetadata({ + videoId, + name: video.name, + sourceType: video.source.type, + webUrl: buildEnv.NEXT_PUBLIC_WEB_URL, + }), robots: "index, follow", }), }), @@ -132,6 +91,7 @@ export default async function EmbedVideoPage( const searchParams = await props.searchParams; const videoId = params.videoId as Video.VideoId; const autoplay = searchParams.autoplay === "true"; + const minimal = searchParams.slack === "true"; return Effect.gen(function* () { const videosPolicy = yield* VideosPolicy; @@ -193,10 +153,21 @@ export default async function EmbedVideoPage( Effect.succeed({ needsPassword: true } as const), ), Effect.map((data) => ( -
+
{!data.needsPassword && ( - + )}
)), @@ -212,6 +183,7 @@ export default async function EmbedVideoPage( async function EmbedContent({ video, autoplay, + minimal, }: { video: Omit & { sharedOrganization: { organizationId: Organisation.OrganisationId } | null; @@ -219,6 +191,7 @@ async function EmbedContent({ orgSettings?: (typeof organizations.$inferSelect)["settings"] | null; }; autoplay: boolean; + minimal: boolean; }) { const user = await getCurrentUser(); const sharedSpaces = await db() @@ -324,6 +297,7 @@ async function EmbedContent({ } ownerName={videoOwner[0]?.name || null} autoplay={autoplay} + minimal={minimal} viewerSettings={rules.settings} showPlaybackStatusBadge={user?.id === video.ownerId} /> diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index d126db52e2e..69073b883ad 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -53,7 +53,7 @@ import { resolveDefaultPlaybackSpeed } from "@/lib/playback-speed"; import * as EffectRuntime from "@/lib/server"; import { runPromise } from "@/lib/server"; import { getSharePageBranding } from "@/lib/share-branding"; -import { getSharePlayerUrl } from "@/lib/share-player-url"; +import { buildShareVideoMetadata } from "@/lib/share-video-metadata"; import { isSocialCrawlerUserAgent, SOCIAL_REFERRER_DOMAINS, @@ -239,69 +239,13 @@ export async function generateMetadata( } : notFound(), onSome: ([video]) => { - const previewImageUrl = new URL( - `/api/video/preview?videoId=${videoId}&fallback=og`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(); - const ogImageUrl = new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(); - const playlistUrl = new URL( - `/api/playlist?videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(); - return { - title: `${video.name} | Cap Recording`, - description: "Watch this video on Cap", - openGraph: { - images: [ - { - url: previewImageUrl, - width: 480, - height: 270, - type: "image/gif", - }, - { - url: ogImageUrl, - width: 1200, - height: 630, - }, - ], - videos: [ - { - url: playlistUrl, - width: 1280, - height: 720, - type: "video/mp4", - }, - ], - }, - twitter: { - card: "player", - title: `${video.name} | Cap Recording`, - description: "Watch this video on Cap", - images: [ - { - url: previewImageUrl, - width: 480, - height: 270, - type: "image/gif", - }, - { - url: ogImageUrl, - width: 1200, - height: 630, - }, - ], - players: { - playerUrl: getSharePlayerUrl(videoId), - streamUrl: playlistUrl, - width: 1280, - height: 720, - }, - }, + ...buildShareVideoMetadata({ + videoId, + name: video.name, + sourceType: video.source.type, + webUrl: buildEnv.NEXT_PUBLIC_WEB_URL, + }), robots: canRenderSocialPreview ? "index, follow" : "noindex, nofollow", @@ -325,17 +269,6 @@ export async function generateMetadata( height: 630, }, ], - videos: [ - { - url: new URL( - `/api/playlist?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1280, - height: 720, - type: "video/mp4", - }, - ], }, robots: "noindex, nofollow", }), diff --git a/apps/web/lib/integrations/installations.ts b/apps/web/lib/integrations/installations.ts new file mode 100644 index 00000000000..26c0b8f4b1c --- /dev/null +++ b/apps/web/lib/integrations/installations.ts @@ -0,0 +1,174 @@ +import { db } from "@cap/database"; +import { decrypt, encrypt } from "@cap/database/crypto"; +import { nanoId } from "@cap/database/helpers"; +import { integrationInstallations } from "@cap/database/schema"; +import type { Organisation, User } from "@cap/web-domain"; +import { and, asc, eq, sql } from "drizzle-orm"; + +export type IntegrationInstallationSummary = { + id: string; + externalId: string; + displayName: string; + createdAt: Date; + updatedAt: Date; +}; + +export const saveIntegrationInstallation = async ({ + provider, + externalId, + displayName, + organizationId, + installedByUserId, + credentials, + metadata, +}: { + provider: string; + externalId: string; + displayName: string; + organizationId: Organisation.OrganisationId; + installedByUserId: User.UserId; + credentials: Record; + metadata: Record; +}) => { + const encryptedCredentials = await encrypt(JSON.stringify(credentials)); + const candidateId = nanoId(); + await db().transaction(async (tx) => { + await tx + .insert(integrationInstallations) + .values({ + id: candidateId, + provider, + externalId, + displayName, + organizationId, + installedByUserId, + encryptedCredentials, + metadata, + }) + .onDuplicateKeyUpdate({ + set: { + externalId: sql`${integrationInstallations.externalId}`, + }, + }); + + const [installation] = await tx + .select({ + id: integrationInstallations.id, + organizationId: integrationInstallations.organizationId, + }) + .from(integrationInstallations) + .where( + and( + eq(integrationInstallations.provider, provider), + eq(integrationInstallations.externalId, externalId), + ), + ) + .for("update"); + if (!installation) { + throw new Error("Integration installation could not be saved"); + } + if (installation.organizationId !== organizationId) { + throw new Error( + "Integration is already connected to another organization", + ); + } + if (installation.id === candidateId) return; + + await tx + .update(integrationInstallations) + .set({ + displayName, + installedByUserId, + encryptedCredentials, + metadata, + updatedAt: new Date(), + }) + .where(eq(integrationInstallations.id, installation.id)); + }); +}; + +export const listIntegrationInstallations = async ({ + organizationId, + provider, +}: { + organizationId: Organisation.OrganisationId; + provider: string; +}): Promise => + db() + .select({ + id: integrationInstallations.id, + externalId: integrationInstallations.externalId, + displayName: integrationInstallations.displayName, + createdAt: integrationInstallations.createdAt, + updatedAt: integrationInstallations.updatedAt, + }) + .from(integrationInstallations) + .where( + and( + eq(integrationInstallations.organizationId, organizationId), + eq(integrationInstallations.provider, provider), + ), + ) + .orderBy(asc(integrationInstallations.displayName)); + +export const getIntegrationCredentials = async ({ + provider, + externalId, +}: { + provider: string; + externalId: string; +}): Promise => { + const [installation] = await db() + .select({ + encryptedCredentials: integrationInstallations.encryptedCredentials, + }) + .from(integrationInstallations) + .where( + and( + eq(integrationInstallations.provider, provider), + eq(integrationInstallations.externalId, externalId), + ), + ) + .limit(1); + if (!installation) return null; + return JSON.parse( + await decrypt(installation.encryptedCredentials), + ) as unknown; +}; + +export const deleteIntegrationInstallation = async ({ + provider, + organizationId, + installationId, +}: { + provider: string; + organizationId: Organisation.OrganisationId; + installationId: string; +}) => { + await db() + .delete(integrationInstallations) + .where( + and( + eq(integrationInstallations.provider, provider), + eq(integrationInstallations.id, installationId), + eq(integrationInstallations.organizationId, organizationId), + ), + ); +}; + +export const deleteIntegrationInstallationByExternalId = async ({ + provider, + externalId, +}: { + provider: string; + externalId: string; +}) => { + await db() + .delete(integrationInstallations) + .where( + and( + eq(integrationInstallations.provider, provider), + eq(integrationInstallations.externalId, externalId), + ), + ); +}; diff --git a/apps/web/lib/oembed.ts b/apps/web/lib/oembed.ts new file mode 100644 index 00000000000..d6d72965a2b --- /dev/null +++ b/apps/web/lib/oembed.ts @@ -0,0 +1,131 @@ +import type { PublicShareVideo } from "@/lib/public-share-video"; + +const DEFAULT_WIDTH = 800; +const DEFAULT_HEIGHT = 450; +const MAX_DIMENSION = 1920; + +export type OEmbedVideo = { + version: "1.0"; + type: "video"; + provider_name: "Cap"; + provider_url: string; + title: string; + author_name?: string; + cache_age: number; + html: string; + width: number; + height: number; + thumbnail_url?: string; + thumbnail_width?: number; + thumbnail_height?: number; + duration?: number; +}; + +const parsePositiveDimension = (value: string | undefined) => { + if (value === undefined) return undefined; + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) return undefined; + return Math.min(Math.floor(number), MAX_DIMENSION); +}; + +const scaleDimensions = ({ + sourceWidth, + sourceHeight, + maxWidth, + maxHeight, +}: { + sourceWidth: number; + sourceHeight: number; + maxWidth?: number; + maxHeight?: number; +}) => { + const widthScale = maxWidth ? maxWidth / sourceWidth : 1; + const heightScale = maxHeight ? maxHeight / sourceHeight : 1; + const scale = Math.min(1, widthScale, heightScale); + + return { + width: Math.max(1, Math.round(sourceWidth * scale)), + height: Math.max(1, Math.round(sourceHeight * scale)), + }; +}; + +export const parseCapShareUrl = (value: string) => { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + + if (url.protocol !== "https:") return null; + const hostname = url.hostname.toLowerCase().replace(/^www\./, ""); + const segments = url.pathname.split("/").filter(Boolean); + let videoId: string | undefined; + + if (hostname === "cap.so" && segments.length === 2 && segments[0] === "s") { + videoId = segments[1]; + } + if ( + hostname === "cap.link" && + segments.length === 1 && + segments[0] !== "video" + ) { + videoId = segments[0]; + } + + if (!videoId || !/^[A-Za-z0-9_-]{1,128}$/.test(videoId)) return null; + return videoId; +}; + +export const buildOEmbedVideo = ({ + video, + webUrl, + maxWidth, + maxHeight, +}: { + video: PublicShareVideo; + webUrl: string; + maxWidth?: string; + maxHeight?: string; +}): OEmbedVideo => { + const parsedMaxWidth = parsePositiveDimension(maxWidth); + const parsedMaxHeight = parsePositiveDimension(maxHeight); + const sourceWidth = + video.width && video.width > 0 ? video.width : DEFAULT_WIDTH; + const sourceHeight = + video.height && video.height > 0 ? video.height : DEFAULT_HEIGHT; + const dimensions = scaleDimensions({ + sourceWidth, + sourceHeight, + maxWidth: parsedMaxWidth, + maxHeight: parsedMaxHeight, + }); + const embedUrl = new URL(`/embed/${video.id}`, webUrl).toString(); + const thumbnailUrl = new URL("/api/video/og", webUrl); + thumbnailUrl.searchParams.set("videoId", video.id); + const html = ``; + + return { + version: "1.0", + type: "video", + provider_name: "Cap", + provider_url: new URL("/", webUrl).toString(), + title: video.name, + ...(video.ownerName ? { author_name: video.ownerName } : {}), + cache_age: 300, + html, + width: dimensions.width, + height: dimensions.height, + ...((parsedMaxWidth === undefined || parsedMaxWidth >= 1200) && + (parsedMaxHeight === undefined || parsedMaxHeight >= 630) + ? { + thumbnail_url: thumbnailUrl.toString(), + thumbnail_width: 1200, + thumbnail_height: 630, + } + : {}), + ...(video.duration && video.duration > 0 + ? { duration: Math.round(video.duration) } + : {}), + }; +}; diff --git a/apps/web/lib/public-share-video.ts b/apps/web/lib/public-share-video.ts new file mode 100644 index 00000000000..da299be45c4 --- /dev/null +++ b/apps/web/lib/public-share-video.ts @@ -0,0 +1,129 @@ +import { db } from "@cap/database"; +import { + organizations, + spaces, + spaceVideos, + users, + videos, + videoUploads, +} from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; +import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"; + +export type PublicShareVideoCandidate = { + id: Video.VideoId; + name: string; + ownerId: string; + ownerName: string | null; + public: boolean; + hasPassword: boolean; + hasInheritedPassword: boolean; + allowedEmailDomain: string | null; + isScreenshot: boolean; + hasActiveUpload: boolean; + sourceType: + | "MediaConvert" + | "local" + | "desktopMP4" + | "desktopSegments" + | "webMP4"; + jobStatus: string | null; + skipProcessing: boolean | null; + width: number | null; + height: number | null; + duration: number | null; +}; + +export type PublicShareVideo = Omit< + PublicShareVideoCandidate, + | "public" + | "hasPassword" + | "hasInheritedPassword" + | "allowedEmailDomain" + | "isScreenshot" + | "hasActiveUpload" + | "jobStatus" + | "skipProcessing" +>; + +export const isPublicShareVideoCandidateEligible = ( + video: PublicShareVideoCandidate, +) => + video.public && + !video.hasPassword && + !video.hasInheritedPassword && + (video.allowedEmailDomain?.trim().length ?? 0) === 0 && + !video.isScreenshot && + !video.hasActiveUpload && + (video.sourceType !== "MediaConvert" || + video.jobStatus === "COMPLETE" || + video.skipProcessing === true); + +export async function getPublicShareVideo( + videoId: Video.VideoId, +): Promise { + const [video] = await db() + .select({ + id: videos.id, + name: videos.name, + ownerId: videos.ownerId, + ownerName: users.name, + public: videos.public, + hasPassword: sql`${videos.password} IS NOT NULL`.mapWith( + Boolean, + ), + allowedEmailDomain: organizations.allowedEmailDomain, + isScreenshot: videos.isScreenshot, + hasActiveUpload: + sql`${videoUploads.videoId} IS NOT NULL AND ${videoUploads.phase} <> 'complete'`.mapWith( + Boolean, + ), + sourceType: sql< + PublicShareVideoCandidate["sourceType"] + >`JSON_UNQUOTE(JSON_EXTRACT(${videos.source}, '$.type'))`, + jobStatus: videos.jobStatus, + skipProcessing: videos.skipProcessing, + width: videos.width, + height: videos.height, + duration: videos.duration, + }) + .from(videos) + .innerJoin( + organizations, + and( + eq(videos.orgId, organizations.id), + isNull(organizations.tombstoneAt), + ), + ) + .leftJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .where(eq(videos.id, videoId)) + .limit(1); + + if (!video) return null; + + const [inheritedPassword] = await db() + .select({ id: spaces.id }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .where(and(eq(spaceVideos.videoId, videoId), isNotNull(spaces.password))) + .limit(1); + + const candidate: PublicShareVideoCandidate = { + ...video, + hasInheritedPassword: Boolean(inheritedPassword), + }; + + if (!isPublicShareVideoCandidateEligible(candidate)) return null; + + return { + id: candidate.id, + name: candidate.name, + ownerId: candidate.ownerId, + ownerName: candidate.ownerName, + sourceType: candidate.sourceType, + width: candidate.width, + height: candidate.height, + duration: candidate.duration, + }; +} diff --git a/apps/web/lib/share-video-metadata.ts b/apps/web/lib/share-video-metadata.ts new file mode 100644 index 00000000000..9809419aa2b --- /dev/null +++ b/apps/web/lib/share-video-metadata.ts @@ -0,0 +1,130 @@ +import type { Metadata } from "next"; + +const PLAYER_WIDTH = 1280; +const PLAYER_HEIGHT = 720; + +export type ShareVideoSourceType = + | "MediaConvert" + | "local" + | "desktopMP4" + | "desktopSegments" + | "webMP4"; + +export type ShareVideoMetadataInput = { + videoId: string; + name: string; + sourceType: ShareVideoSourceType; + webUrl: string; +}; + +export const getShareVideoUrls = ({ + videoId, + sourceType, + webUrl, +}: Pick) => { + const shareUrl = new URL(`/s/${videoId}`, webUrl).toString(); + const playerUrl = new URL(`/embed/${videoId}`, webUrl).toString(); + const streamUrl = new URL("/api/playlist", webUrl); + streamUrl.searchParams.set("videoId", videoId); + let streamContentType = "application/vnd.apple.mpegurl"; + if (sourceType === "desktopMP4" || sourceType === "webMP4") { + streamUrl.searchParams.set("videoType", "mp4"); + streamContentType = "video/mp4"; + } else if (sourceType === "desktopSegments") { + streamUrl.searchParams.set("videoType", "segments-master"); + streamUrl.searchParams.set("requireComplete", "1"); + } else { + streamUrl.searchParams.set("videoType", "master"); + } + const previewImageUrl = new URL("/api/video/preview", webUrl); + previewImageUrl.searchParams.set("videoId", videoId); + previewImageUrl.searchParams.set("fallback", "og"); + const ogImageUrl = new URL("/api/video/og", webUrl); + ogImageUrl.searchParams.set("videoId", videoId); + const oEmbedUrl = new URL("/api/oembed", webUrl); + oEmbedUrl.searchParams.set("url", shareUrl); + oEmbedUrl.searchParams.set("format", "json"); + + return { + shareUrl, + playerUrl, + streamUrl: streamUrl.toString(), + streamContentType, + previewImageUrl: previewImageUrl.toString(), + ogImageUrl: ogImageUrl.toString(), + oEmbedUrl: oEmbedUrl.toString(), + }; +}; + +export const buildShareVideoMetadata = ({ + videoId, + name, + sourceType, + webUrl, +}: ShareVideoMetadataInput): Metadata => { + const urls = getShareVideoUrls({ videoId, sourceType, webUrl }); + const title = `${name} | Cap Recording`; + const description = "Watch this video on Cap"; + + return { + title, + description, + alternates: { + canonical: urls.shareUrl, + types: { + "application/json+oembed": [ + { + title, + url: urls.oEmbedUrl, + }, + ], + }, + }, + openGraph: { + type: "video.other", + url: urls.shareUrl, + siteName: "Cap", + title, + description, + ttl: 300, + images: [ + { + url: urls.previewImageUrl, + width: 480, + height: 270, + type: "image/gif", + }, + { + url: urls.ogImageUrl, + width: 1200, + height: 630, + type: "image/png", + }, + ], + videos: [ + { + url: urls.streamUrl, + secureUrl: urls.streamUrl, + width: PLAYER_WIDTH, + height: PLAYER_HEIGHT, + type: urls.streamContentType, + }, + ], + }, + twitter: { + card: "player", + title, + description, + images: [urls.previewImageUrl, urls.ogImageUrl], + players: { + playerUrl: urls.playerUrl, + streamUrl: urls.streamUrl, + width: PLAYER_WIDTH, + height: PLAYER_HEIGHT, + }, + }, + other: { + "twitter:player:stream:content_type": urls.streamContentType, + }, + }; +}; diff --git a/apps/web/lib/slack/client.ts b/apps/web/lib/slack/client.ts new file mode 100644 index 00000000000..cf4255ab770 --- /dev/null +++ b/apps/web/lib/slack/client.ts @@ -0,0 +1,222 @@ +import { serverEnv } from "@cap/env"; + +export const SLACK_BOT_SCOPES = [ + "links:read", + "links:write", + "links.embed:write", +] as const; + +type SlackConfig = { + clientId: string; + clientSecret: string; + signingSecret: string; + redirectUrl: string; +}; + +export type SlackOAuthResult = { + accessToken: string; + scope: string; + botUserId: string | null; + teamId: string; + teamName: string; + enterpriseId: string | null; +}; + +type SlackApiResponse = { + ok?: boolean; + error?: string; +}; + +export class SlackUnfurlError extends Error { + readonly retryable: boolean; + + constructor(message: string, retryable: boolean) { + super(message); + this.name = "SlackUnfurlError"; + this.retryable = retryable; + } +} + +const sleep = (durationMs: number) => + new Promise((resolve) => setTimeout(resolve, durationMs)); + +const parseSlackResponse = async (response: Response) => { + let value: unknown; + try { + value = await response.json(); + } catch { + throw new Error(`Slack returned an invalid response (${response.status})`); + } + if (!value || typeof value !== "object") { + throw new Error(`Slack returned an invalid response (${response.status})`); + } + return value as SlackApiResponse & Record; +}; + +export const getSlackConfig = (): SlackConfig | null => { + const env = serverEnv(); + if ( + !env.SLACK_CLIENT_ID || + !env.SLACK_CLIENT_SECRET || + !env.SLACK_SIGNING_SECRET + ) { + return null; + } + + return { + clientId: env.SLACK_CLIENT_ID, + clientSecret: env.SLACK_CLIENT_SECRET, + signingSecret: env.SLACK_SIGNING_SECRET, + redirectUrl: new URL( + "/api/integrations/slack/callback", + env.WEB_URL, + ).toString(), + }; +}; + +export const isSlackIntegrationConfigured = () => + getSlackConfig() !== null && + /^[0-9a-f]{64}$/i.test(serverEnv().DATABASE_ENCRYPTION_KEY ?? ""); + +export const buildSlackInstallUrl = ({ + config, + state, +}: { + config: SlackConfig; + state: string; +}) => { + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.searchParams.set("client_id", config.clientId); + url.searchParams.set("scope", SLACK_BOT_SCOPES.join(",")); + url.searchParams.set("redirect_uri", config.redirectUrl); + url.searchParams.set("state", state); + return url.toString(); +}; + +export const exchangeSlackOAuthCode = async ({ + code, + config, + fetchImpl = fetch, +}: { + code: string; + config: SlackConfig; + fetchImpl?: typeof fetch; +}): Promise => { + const response = await fetchImpl("https://slack.com/api/oauth.v2.access", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: config.redirectUrl, + }), + signal: AbortSignal.timeout(10_000), + }); + const body = await parseSlackResponse(response); + if (!response.ok || body.ok !== true) { + throw new Error( + `Slack OAuth failed: ${typeof body.error === "string" ? body.error : response.status}`, + ); + } + + const accessToken = body.access_token; + const grantedScopes = + typeof body.scope === "string" + ? body.scope + .split(",") + .map((scope) => scope.trim()) + .filter(Boolean) + : []; + const team = body.team; + const enterprise = body.enterprise; + if ( + typeof accessToken !== "string" || + !team || + typeof team !== "object" || + typeof (team as { id?: unknown }).id !== "string" || + typeof (team as { name?: unknown }).name !== "string" + ) { + throw new Error("Slack OAuth response is missing workspace credentials"); + } + if (!SLACK_BOT_SCOPES.every((scope) => grantedScopes.includes(scope))) { + throw new Error("Slack OAuth response is missing required unfurl scopes"); + } + + return { + accessToken, + scope: grantedScopes.join(","), + botUserId: typeof body.bot_user_id === "string" ? body.bot_user_id : null, + teamId: (team as { id: string }).id, + teamName: (team as { name: string }).name, + enterpriseId: + enterprise && + typeof enterprise === "object" && + typeof (enterprise as { id?: unknown }).id === "string" + ? (enterprise as { id: string }).id + : null, + }; +}; + +export const sendSlackUnfurl = async ({ + token, + event, + unfurls, + fetchImpl = fetch, + sleepImpl = sleep, +}: { + token: string; + event: { + channel: string; + messageTs: string; + unfurlId?: string; + source?: string; + }; + unfurls: Record; + fetchImpl?: typeof fetch; + sleepImpl?: (durationMs: number) => Promise; +}) => { + const locator = + event.unfurlId && event.source + ? { unfurl_id: event.unfurlId, source: event.source } + : { channel: event.channel, ts: event.messageTs }; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const response = await fetchImpl("https://slack.com/api/chat.unfurl", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + ...locator, + unfurls, + }), + signal: AbortSignal.timeout(10_000), + }); + const body = await parseSlackResponse(response); + if (response.ok && body.ok === true) return; + + const retryable = response.status === 429 || response.status >= 500; + if (!retryable || attempt === 3) { + throw new SlackUnfurlError( + `Slack unfurl failed: ${typeof body.error === "string" ? body.error : response.status}`, + retryable, + ); + } + const retryAfterSeconds = Number(response.headers.get("Retry-After")); + const retryAfterMs = + Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 + ? retryAfterSeconds * 1000 + : attempt * 250; + await sleepImpl(Math.min(retryAfterMs, 2000)); + } catch (error) { + if (attempt === 3 || error instanceof SlackUnfurlError) { + throw error; + } + await sleepImpl(attempt * 250); + } + } +}; diff --git a/apps/web/lib/slack/installations.ts b/apps/web/lib/slack/installations.ts new file mode 100644 index 00000000000..fd73b089ba9 --- /dev/null +++ b/apps/web/lib/slack/installations.ts @@ -0,0 +1,108 @@ +import type { Organisation, User } from "@cap/web-domain"; +import { + deleteIntegrationInstallation, + deleteIntegrationInstallationByExternalId, + getIntegrationCredentials, + listIntegrationInstallations, + saveIntegrationInstallation, +} from "@/lib/integrations/installations"; + +const SLACK_PROVIDER = "slack"; + +export type SlackInstallationSummary = { + id: string; + teamId: string; + teamName: string; + createdAt: Date; + updatedAt: Date; +}; + +export const saveSlackInstallation = async ({ + organizationId, + installedByUserId, + teamId, + teamName, + enterpriseId, + botUserId, + accessToken, + scope, +}: { + organizationId: Organisation.OrganisationId; + installedByUserId: User.UserId; + teamId: string; + teamName: string; + enterpriseId: string | null; + botUserId: string | null; + accessToken: string; + scope: string; +}) => { + await saveIntegrationInstallation({ + provider: SLACK_PROVIDER, + externalId: teamId, + displayName: teamName, + organizationId, + installedByUserId, + credentials: { accessToken }, + metadata: { + enterpriseId, + botUserId, + scopes: scope + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + }, + }); +}; + +export const listSlackInstallations = async ( + organizationId: Organisation.OrganisationId, +): Promise => + listIntegrationInstallations({ + organizationId, + provider: SLACK_PROVIDER, + }).then((installations) => + installations.map((installation) => ({ + id: installation.id, + teamId: installation.externalId, + teamName: installation.displayName, + createdAt: installation.createdAt, + updatedAt: installation.updatedAt, + })), + ); + +export const getSlackInstallationToken = async (teamId: string) => { + const credentials = await getIntegrationCredentials({ + provider: SLACK_PROVIDER, + externalId: teamId, + }); + if (credentials === null) return null; + if ( + typeof credentials !== "object" || + Array.isArray(credentials) || + typeof (credentials as { accessToken?: unknown }).accessToken !== "string" + ) { + throw new Error("Slack installation credentials are invalid"); + } + return (credentials as { accessToken: string }).accessToken; +}; + +export const deleteSlackInstallation = async ({ + organizationId, + installationId, +}: { + organizationId: Organisation.OrganisationId; + installationId: string; +}) => { + await deleteIntegrationInstallation({ + provider: SLACK_PROVIDER, + organizationId, + installationId, + }); +}; + +export const deleteSlackInstallationByTeamId = async (teamId: string) => { + await deleteIntegrationInstallationByExternalId({ + provider: SLACK_PROVIDER, + externalId: teamId, + }); +}; diff --git a/apps/web/lib/slack/oauth-state.ts b/apps/web/lib/slack/oauth-state.ts new file mode 100644 index 00000000000..ad288282802 --- /dev/null +++ b/apps/web/lib/slack/oauth-state.ts @@ -0,0 +1,100 @@ +import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; + +const STATE_VERSION = 1; +const STATE_TTL_SECONDS = 60 * 10; +const FUTURE_SKEW_SECONDS = 60; + +type SlackOAuthStatePayload = { + v: typeof STATE_VERSION; + organizationId: string; + userId: string; + issuedAt: number; + nonce: string; +}; + +const signState = (payload: string, secret: string) => + createHmac("sha256", secret).update(payload).digest("base64url"); + +export const createSlackOAuthState = ({ + organizationId, + userId, + secret, + now = Date.now(), + nonce = randomUUID(), +}: { + organizationId: string; + userId: string; + secret: string; + now?: number; + nonce?: string; +}) => { + const encoded = Buffer.from( + JSON.stringify({ + v: STATE_VERSION, + organizationId, + userId, + issuedAt: Math.floor(now / 1000), + nonce, + } satisfies SlackOAuthStatePayload), + ).toString("base64url"); + return `${encoded}.${signState(encoded, secret)}`; +}; + +const isPayload = (value: unknown): value is SlackOAuthStatePayload => { + if (!value || typeof value !== "object") return false; + const payload = value as Partial; + return ( + payload.v === STATE_VERSION && + typeof payload.organizationId === "string" && + payload.organizationId.length > 0 && + payload.organizationId.length <= 128 && + typeof payload.userId === "string" && + payload.userId.length > 0 && + payload.userId.length <= 128 && + typeof payload.issuedAt === "number" && + Number.isSafeInteger(payload.issuedAt) && + typeof payload.nonce === "string" && + payload.nonce.length > 0 && + payload.nonce.length <= 128 + ); +}; + +export const verifySlackOAuthState = ({ + state, + secret, + now = Date.now(), +}: { + state: string; + secret: string; + now?: number; +}): SlackOAuthStatePayload | null => { + const [encoded, signature, extra] = state.split("."); + if (!encoded || !signature || extra) return null; + const expected = signState(encoded, secret); + const expectedBuffer = Buffer.from(expected); + const signatureBuffer = Buffer.from(signature); + if ( + expectedBuffer.length !== signatureBuffer.length || + !timingSafeEqual(expectedBuffer, signatureBuffer) + ) { + return null; + } + + let payload: unknown; + try { + payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + } catch { + return null; + } + if (!isPayload(payload)) return null; + + const nowSeconds = Math.floor(now / 1000); + if ( + payload.issuedAt > nowSeconds + FUTURE_SKEW_SECONDS || + nowSeconds - payload.issuedAt > STATE_TTL_SECONDS + ) { + return null; + } + + return payload; +}; diff --git a/apps/web/lib/slack/signature.ts b/apps/web/lib/slack/signature.ts new file mode 100644 index 00000000000..cd65a5bce54 --- /dev/null +++ b/apps/web/lib/slack/signature.ts @@ -0,0 +1,46 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +const SIGNATURE_VERSION = "v0"; +const DEFAULT_TOLERANCE_SECONDS = 60 * 5; + +export const createSlackSignature = ({ + body, + timestamp, + signingSecret, +}: { + body: string; + timestamp: string; + signingSecret: string; +}) => + `${SIGNATURE_VERSION}=${createHmac("sha256", signingSecret) + .update(`${SIGNATURE_VERSION}:${timestamp}:${body}`) + .digest("hex")}`; + +export const verifySlackSignature = ({ + body, + timestamp, + signature, + signingSecret, + now = Date.now(), + toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, +}: { + body: string; + timestamp: string | undefined; + signature: string | undefined; + signingSecret: string; + now?: number; + toleranceSeconds?: number; +}) => { + if (!timestamp || !signature || !/^\d+$/.test(timestamp)) return false; + const requestTime = Number(timestamp); + if (!Number.isSafeInteger(requestTime)) return false; + if (Math.abs(Math.floor(now / 1000) - requestTime) > toleranceSeconds) { + return false; + } + + const expected = createSlackSignature({ body, timestamp, signingSecret }); + const expectedBuffer = Buffer.from(expected); + const signatureBuffer = Buffer.from(signature); + if (expectedBuffer.length !== signatureBuffer.length) return false; + return timingSafeEqual(expectedBuffer, signatureBuffer); +}; diff --git a/apps/web/lib/slack/unfurl.ts b/apps/web/lib/slack/unfurl.ts new file mode 100644 index 00000000000..a3bc771b462 --- /dev/null +++ b/apps/web/lib/slack/unfurl.ts @@ -0,0 +1,221 @@ +import { Video } from "@cap/web-domain"; +import { parseCapShareUrl } from "@/lib/oembed"; +import { + getPublicShareVideo, + type PublicShareVideo, +} from "@/lib/public-share-video"; +import { sendSlackUnfurl } from "./client"; +import { + deleteSlackInstallationByTeamId, + getSlackInstallationToken, +} from "./installations"; + +type SlackLink = { + domain: string; + url: string; +}; + +export type SlackLinkSharedEvent = { + type: "link_shared"; + channel: string; + message_ts: string; + unfurl_id?: string; + source?: string; + links: SlackLink[]; +}; + +export type SlackEventPayload = + | { + type: "event_callback"; + team_id: string; + event_id?: string; + event: SlackLinkSharedEvent | { type: "app_uninstalled" }; + } + | { + type: "url_verification"; + challenge: string; + }; + +const truncate = (value: string, maxLength: number) => + Array.from(value).slice(0, maxLength).join(""); + +const formatDuration = (duration: number | null) => { + if (!duration || duration <= 0) return "Watch this recording on Cap"; + const seconds = Math.round(duration); + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `Watch this ${minutes}:${remainder.toString().padStart(2, "0")} recording on Cap`; +}; + +export const buildSlackVideoBlock = ({ + video, + shareUrl, + webUrl, +}: { + video: PublicShareVideo; + shareUrl: string; + webUrl: string; +}) => { + const title = truncate(video.name.trim() || "Cap recording", 199); + const embedUrl = new URL(`/embed/${video.id}`, webUrl); + embedUrl.searchParams.set("autoplay", "true"); + embedUrl.searchParams.set("slack", "true"); + const thumbnailUrl = new URL("/api/video/og", webUrl); + thumbnailUrl.searchParams.set("videoId", video.id); + + return { + type: "video", + title: { + type: "plain_text", + text: title, + emoji: true, + }, + title_url: shareUrl, + description: { + type: "plain_text", + text: formatDuration(video.duration), + emoji: true, + }, + video_url: embedUrl.toString(), + alt_text: truncate(`Watch ${video.name}`, 200), + thumbnail_url: thumbnailUrl.toString(), + provider_name: "Cap", + ...(video.ownerName ? { author_name: truncate(video.ownerName, 49) } : {}), + }; +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +export const parseSlackEventPayload = ( + value: unknown, +): SlackEventPayload | null => { + if (!isRecord(value) || typeof value.type !== "string") return null; + if ( + value.type === "url_verification" && + typeof value.challenge === "string" + ) { + return { type: "url_verification", challenge: value.challenge }; + } + if ( + value.type !== "event_callback" || + typeof value.team_id !== "string" || + !isRecord(value.event) || + typeof value.event.type !== "string" + ) { + return null; + } + if (value.event.type === "app_uninstalled") { + return { + type: "event_callback", + team_id: value.team_id, + event_id: typeof value.event_id === "string" ? value.event_id : undefined, + event: { type: "app_uninstalled" }, + }; + } + if ( + value.event.type !== "link_shared" || + typeof value.event.channel !== "string" || + typeof value.event.message_ts !== "string" || + !Array.isArray(value.event.links) + ) { + return null; + } + + const links = value.event.links + .filter(isRecord) + .filter( + (link): link is SlackLink => + typeof link.domain === "string" && typeof link.url === "string", + ); + if (links.length === 0) return null; + + return { + type: "event_callback", + team_id: value.team_id, + event_id: typeof value.event_id === "string" ? value.event_id : undefined, + event: { + type: "link_shared", + channel: value.event.channel, + message_ts: value.event.message_ts, + unfurl_id: + typeof value.event.unfurl_id === "string" + ? value.event.unfurl_id + : undefined, + source: + typeof value.event.source === "string" ? value.event.source : undefined, + links, + }, + }; +}; + +type ProcessSlackEventDependencies = { + deleteInstallation: typeof deleteSlackInstallationByTeamId; + getInstallationToken: typeof getSlackInstallationToken; + getVideo: typeof getPublicShareVideo; + sendUnfurl: typeof sendSlackUnfurl; +}; + +const defaultDependencies: ProcessSlackEventDependencies = { + deleteInstallation: deleteSlackInstallationByTeamId, + getInstallationToken: getSlackInstallationToken, + getVideo: getPublicShareVideo, + sendUnfurl: sendSlackUnfurl, +}; + +export const processSlackEvent = async ({ + payload, + webUrl, + dependencies = defaultDependencies, +}: { + payload: Exclude; + webUrl: string; + dependencies?: ProcessSlackEventDependencies; +}) => { + if (payload.event.type === "app_uninstalled") { + await dependencies.deleteInstallation(payload.team_id); + return; + } + + const token = await dependencies.getInstallationToken(payload.team_id); + if (!token) return; + + const uniqueLinks = [ + ...new Map(payload.event.links.map((link) => [link.url, link])).values(), + ].slice(0, 5); + const resolved = await Promise.all( + uniqueLinks.map(async (link) => { + const videoId = parseCapShareUrl(link.url); + if (!videoId) return null; + const video = await dependencies.getVideo(Video.VideoId.make(videoId)); + if (!video) return null; + const shareUrl = new URL(`/s/${video.id}`, webUrl).toString(); + const blocks: unknown[] = [ + buildSlackVideoBlock({ video, shareUrl, webUrl }), + ]; + return [ + link.url, + { + blocks, + }, + ] as const; + }), + ); + const unfurls = Object.fromEntries( + resolved.filter( + (entry): entry is NonNullable => entry !== null, + ), + ); + if (Object.keys(unfurls).length === 0) return; + + await dependencies.sendUnfurl({ + token, + event: { + channel: payload.event.channel, + messageTs: payload.event.message_ts, + unfurlId: payload.event.unfurl_id, + source: payload.event.source, + }, + unfurls, + }); +}; diff --git a/apps/web/slack-app-manifest.json b/apps/web/slack-app-manifest.json new file mode 100644 index 00000000000..87744a5bb93 --- /dev/null +++ b/apps/web/slack-app-manifest.json @@ -0,0 +1,33 @@ +{ + "_metadata": { + "major_version": 1, + "minor_version": 1 + }, + "display_information": { + "name": "Cap", + "description": "Use Cap directly inside Slack.", + "background_color": "#4785FF" + }, + "features": { + "bot_user": { + "display_name": "cap", + "always_online": false + }, + "unfurl_domains": ["cap.so", "cap.link"] + }, + "oauth_config": { + "redirect_urls": ["https://cap.so/api/integrations/slack/callback"], + "scopes": { + "bot": ["links:read", "links:write", "links.embed:write"] + } + }, + "settings": { + "event_subscriptions": { + "request_url": "https://cap.so/api/integrations/slack/events", + "bot_events": ["link_shared", "app_uninstalled"] + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} diff --git a/apps/web/workflows/slack-event.ts b/apps/web/workflows/slack-event.ts new file mode 100644 index 00000000000..cd1dfc61d09 --- /dev/null +++ b/apps/web/workflows/slack-event.ts @@ -0,0 +1,51 @@ +import { sleep } from "workflow"; +import { SlackUnfurlError } from "@/lib/slack/client"; +import type { SlackEventPayload } from "@/lib/slack/unfurl"; +import { processSlackEvent } from "@/lib/slack/unfurl"; + +type ProcessableSlackEventPayload = Exclude< + SlackEventPayload, + { type: "url_verification" } +>; + +async function processSlackEventStep( + payload: ProcessableSlackEventPayload, + webUrl: string, +) { + "use step"; + + try { + await processSlackEvent({ payload, webUrl }); + } catch (error) { + if (error instanceof SlackUnfurlError && !error.retryable) { + console.error( + "[slack-unfurl] Permanent Slack API failure", + error.message, + ); + return; + } + throw error; + } +} +processSlackEventStep.maxRetries = 5; + +export async function slackEventWorkflow( + payload: ProcessableSlackEventPayload, + webUrl: string, +) { + "use workflow"; + + let processed = false; + while (!processed) { + try { + await processSlackEventStep(payload, webUrl); + processed = true; + } catch (error) { + console.error( + "[slack-unfurl] Retrying after step failure", + error instanceof Error ? error.message : "Unknown error", + ); + await sleep("5m"); + } + } +} diff --git a/packages/database/migrations/0037_integration_installations.sql b/packages/database/migrations/0037_integration_installations.sql new file mode 100644 index 00000000000..af2ce03dffd --- /dev/null +++ b/packages/database/migrations/0037_integration_installations.sql @@ -0,0 +1,16 @@ +CREATE TABLE `integration_installations` ( + `id` varchar(15) NOT NULL, + `provider` varchar(64) NOT NULL, + `externalId` varchar(255) NOT NULL, + `displayName` varchar(255) NOT NULL, + `organizationId` varchar(15) NOT NULL, + `installedByUserId` varchar(15) NOT NULL, + `encryptedCredentials` text NOT NULL, + `metadata` json NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `integration_installations_id` PRIMARY KEY(`id`), + CONSTRAINT `provider_external_id_idx` UNIQUE(`provider`,`externalId`) +); +--> statement-breakpoint +CREATE INDEX `organization_provider_display_name_idx` ON `integration_installations` (`organizationId`,`provider`,`displayName`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0037_snapshot.json b/packages/database/migrations/meta/0037_snapshot.json new file mode 100644 index 00000000000..f623d581140 --- /dev/null +++ b/packages/database/migrations/meta/0037_snapshot.json @@ -0,0 +1,3933 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "76d6a0f0-dc80-439a-9632-7d808d45e548", + "prevId": "a771184e-113d-4aca-b26b-abc85d419b88", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_in": { + "name": "refresh_token_expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "tempColumn": { + "name": "tempColumn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "provider_account_id_idx": { + "name": "provider_account_id_idx", + "columns": ["providerAccountId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accounts_id": { + "name": "accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_authorization_codes": { + "name": "agent_api_authorization_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_authorization_codes_id": { + "name": "agent_api_authorization_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_idempotency": { + "name": "agent_api_idempotency", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestHash": { + "name": "requestHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "statusCode": { + "name": "statusCode", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_operation_key_idx": { + "name": "user_operation_key_idx", + "columns": ["userId", "operation", "keyHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_idempotency_id": { + "name": "agent_api_idempotency_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_keys": { + "name": "agent_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Cap CLI'" + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "token_hash_idx": { + "name": "token_hash_idx", + "columns": ["tokenHash"], + "isUnique": true + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_keys_id": { + "name": "agent_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_operations": { + "name": "agent_api_operations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resourceId": { + "name": "resourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resultResourceId": { + "name": "resultResourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "state_updated_at_idx": { + "name": "state_updated_at_idx", + "columns": ["state", "updatedAt"], + "isUnique": false + }, + "resource_id_idx": { + "name": "resource_id_idx", + "columns": ["resourceId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_operations_id": { + "name": "agent_api_operations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth_api_keys": { + "name": "auth_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_id_created_at_idx": { + "name": "user_id_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_api_keys_id": { + "name": "auth_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "parentCommentId": { + "name": "parentCommentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_type_created_idx": { + "name": "video_type_created_idx", + "columns": ["videoId", "type", "createdAt", "id"], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": ["authorId"], + "isUnique": false + }, + "parent_comment_id_idx": { + "name": "parent_comment_id_idx", + "columns": ["parentCommentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_api_keys": { + "name": "developer_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyType": { + "name": "keyType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyPrefix": { + "name": "keyPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedKey": { + "name": "encryptedKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "key_hash_idx": { + "name": "key_hash_idx", + "columns": ["keyHash"], + "isUnique": true + }, + "app_key_type_idx": { + "name": "app_key_type_idx", + "columns": ["appId", "keyType"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_api_keys_appId_developer_apps_id_fk": { + "name": "developer_api_keys_appId_developer_apps_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_keys_id": { + "name": "developer_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_app_domains": { + "name": "developer_app_domains", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_app_domains_appId_developer_apps_id_fk": { + "name": "developer_app_domains_appId_developer_apps_id_fk", + "tableFrom": "developer_app_domains", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_app_domains_id": { + "name": "developer_app_domains_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_domain_unique": { + "name": "app_domain_unique", + "columns": ["appId", "domain"] + } + }, + "checkConstraint": {} + }, + "developer_apps": { + "name": "developer_apps", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_deleted_idx": { + "name": "owner_deleted_idx", + "columns": ["ownerId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "developer_apps_id": { + "name": "developer_apps_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_accounts": { + "name": "developer_credit_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceMicroCredits": { + "name": "balanceMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePaymentMethodId": { + "name": "stripePaymentMethodId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoTopUpEnabled": { + "name": "autoTopUpEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autoTopUpThresholdMicroCredits": { + "name": "autoTopUpThresholdMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoTopUpAmountCents": { + "name": "autoTopUpAmountCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_id_unique": { + "name": "app_id_unique", + "columns": ["appId"], + "isUnique": true + } + }, + "foreignKeys": { + "developer_credit_accounts_appId_developer_apps_id_fk": { + "name": "developer_credit_accounts_appId_developer_apps_id_fk", + "tableFrom": "developer_credit_accounts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_accounts_id": { + "name": "developer_credit_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_transactions": { + "name": "developer_credit_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amountMicroCredits": { + "name": "amountMicroCredits", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceAfterMicroCredits": { + "name": "balanceAfterMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceType": { + "name": "referenceType", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "account_type_created_idx": { + "name": "account_type_created_idx", + "columns": ["accountId", "type", "createdAt"], + "isUnique": false + }, + "account_ref_dedup_idx": { + "name": "account_ref_dedup_idx", + "columns": ["accountId", "referenceId", "referenceType"], + "isUnique": false + } + }, + "foreignKeys": { + "dev_credit_txn_account_fk": { + "name": "dev_credit_txn_account_fk", + "tableFrom": "developer_credit_transactions", + "tableTo": "developer_credit_accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_transactions_id": { + "name": "developer_credit_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_daily_storage_snapshots": { + "name": "developer_daily_storage_snapshots", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshotDate": { + "name": "snapshotDate", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalDurationMinutes": { + "name": "totalDurationMinutes", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "videoCount": { + "name": "videoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "microCreditsCharged": { + "name": "microCreditsCharged", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_daily_storage_snapshots_appId_developer_apps_id_fk": { + "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk", + "tableFrom": "developer_daily_storage_snapshots", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_daily_storage_snapshots_id": { + "name": "developer_daily_storage_snapshots_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_date_unique": { + "name": "app_date_unique", + "columns": ["appId", "snapshotDate"] + } + }, + "checkConstraint": {} + }, + "developer_videos": { + "name": "developer_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_created_idx": { + "name": "app_created_idx", + "columns": ["appId", "createdAt"], + "isUnique": false + }, + "app_user_idx": { + "name": "app_user_idx", + "columns": ["appId", "externalUserId"], + "isUnique": false + }, + "app_deleted_idx": { + "name": "app_deleted_idx", + "columns": ["appId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_videos_appId_developer_apps_id_fk": { + "name": "developer_videos_appId_developer_apps_id_fk", + "tableFrom": "developer_videos", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_videos_id": { + "name": "developer_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": ["parentId"], + "isUnique": false + }, + "space_id_idx": { + "name": "space_id_idx", + "columns": ["spaceId"], + "isUnique": false + }, + "public_parent_id_idx": { + "name": "public_parent_id_idx", + "columns": ["public", "parentId"], + "isUnique": false + }, + "public_space_parent_id_idx": { + "name": "public_space_parent_id_idx", + "columns": ["public", "spaceId", "parentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "folders_id": { + "name": "folders_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "imported_videos": { + "name": "imported_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_installations": { + "name": "integration_installations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installedByUserId": { + "name": "installedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedCredentials": { + "name": "encryptedCredentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "provider_external_id_idx": { + "name": "provider_external_id_idx", + "columns": ["provider", "externalId"], + "isUnique": true + }, + "organization_provider_display_name_idx": { + "name": "organization_provider_display_name_idx", + "columns": ["organizationId", "provider", "displayName"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_installations_id": { + "name": "integration_installations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_conversations": { + "name": "messenger_conversations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverByUserId": { + "name": "takeoverByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverAt": { + "name": "takeoverAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_last_message_idx": { + "name": "user_last_message_idx", + "columns": ["userId", "lastMessageAt"], + "isUnique": false + }, + "anonymous_last_message_idx": { + "name": "anonymous_last_message_idx", + "columns": ["anonymousId", "lastMessageAt"], + "isUnique": false + }, + "mode_last_message_idx": { + "name": "mode_last_message_idx", + "columns": ["mode", "lastMessageAt"], + "isUnique": false + }, + "updated_at_idx": { + "name": "updated_at_idx", + "columns": ["updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "messenger_conversations_id": { + "name": "messenger_conversations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_messages": { + "name": "messenger_messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "conversation_created_at_idx": { + "name": "conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + }, + "role_created_at_idx": { + "name": "role_created_at_idx", + "columns": ["role", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "messenger_messages_conversationId_messenger_conversations_id_fk": { + "name": "messenger_messages_conversationId_messenger_conversations_id_fk", + "tableFrom": "messenger_messages", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_messages_id": { + "name": "messenger_messages_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_support_emails": { + "name": "messenger_support_emails", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userEmail": { + "name": "userEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "support_email_user_created_at_idx": { + "name": "support_email_user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "support_email_conversation_created_at_idx": { + "name": "support_email_conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "support_email_conversation_fk": { + "name": "support_email_conversation_fk", + "tableFrom": "messenger_support_emails", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_support_emails_id": { + "name": "messenger_support_emails_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientId": { + "name": "recipientId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": ["orgId"], + "isUnique": false + }, + "type_idx": { + "name": "type_idx", + "columns": ["type"], + "isUnique": false + }, + "read_at_idx": { + "name": "read_at_idx", + "columns": ["readAt"], + "isUnique": false + }, + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + }, + "recipient_read_idx": { + "name": "recipient_read_idx", + "columns": ["recipientId", "readAt"], + "isUnique": false + }, + "recipient_created_idx": { + "name": "recipient_created_idx", + "columns": ["recipientId", "createdAt"], + "isUnique": false + }, + "dedup_key_idx": { + "name": "dedup_key_idx", + "columns": ["dedupKey"], + "isUnique": true + }, + "type_recipient_created_idx": { + "name": "type_recipient_created_idx", + "columns": ["type", "recipientId", "createdAt"], + "isUnique": false + }, + "type_recipient_video_created_idx": { + "name": "type_recipient_video_created_idx", + "columns": ["type", "recipientId", "videoId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": ["invitedEmail"], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": ["invitedByUserId"], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tombstoneAt": { + "name": "tombstoneAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedEmailDomain": { + "name": "allowedEmailDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shareableLinkIconUrl": { + "name": "shareableLinkIconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": ["ownerId", "tombstoneAt"], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": ["customDomain"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketName": { + "name": "bucketName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessKeyId": { + "name": "accessKeyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aws')" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_organization_idx": { + "name": "owner_organization_idx", + "columns": ["ownerId", "organizationId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "s3_buckets_id": { + "name": "s3_buckets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "session_token_idx": { + "name": "session_token_idx", + "columns": ["sessionToken"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_videos": { + "name": "shared_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedByUserId": { + "name": "sharedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "shared_by_user_id_idx": { + "name": "shared_by_user_id_idx", + "columns": ["sharedByUserId"], + "isUnique": false + }, + "video_id_organization_id_idx": { + "name": "video_id_organization_id_idx", + "columns": ["videoId", "organizationId"], + "isUnique": false + }, + "video_id_folder_id_idx": { + "name": "video_id_folder_id_idx", + "columns": ["videoId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "shared_videos_id": { + "name": "shared_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_members_id": { + "name": "space_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "space_id_user_id_unique": { + "name": "space_id_user_id_unique", + "columns": ["spaceId", "userId"] + } + }, + "checkConstraint": {} + }, + "space_videos": { + "name": "space_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedById": { + "name": "addedById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "added_by_id_idx": { + "name": "added_by_id_idx", + "columns": ["addedById"], + "isUnique": false + }, + "space_id_video_id_idx": { + "name": "space_id_video_id_idx", + "columns": ["spaceId", "videoId"], + "isUnique": false + }, + "space_id_folder_id_idx": { + "name": "space_id_folder_id_idx", + "columns": ["spaceId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_videos_id": { + "name": "space_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary": { + "name": "primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "privacy": { + "name": "privacy", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Private'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "public_organization_id_idx": { + "name": "public_organization_id_idx", + "columns": ["public", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spaces_id": { + "name": "spaces_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_integrations": { + "name": "storage_integrations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "encryptedConfig": { + "name": "encryptedConfig", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "googleDriveAccessToken": { + "name": "googleDriveAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveAccessTokenExpiresAt": { + "name": "googleDriveAccessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseId": { + "name": "googleDriveTokenRefreshLeaseId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseExpiresAt": { + "name": "googleDriveTokenRefreshLeaseExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveStorageQuotaCache": { + "name": "googleDriveStorageQuotaCache", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_provider_idx": { + "name": "owner_provider_idx", + "columns": ["ownerId", "provider"], + "isUnique": false + }, + "owner_active_idx": { + "name": "owner_active_idx", + "columns": ["ownerId", "active"], + "isUnique": false + }, + "organization_provider_idx": { + "name": "organization_provider_idx", + "columns": ["organizationId", "provider"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "storage_integrations_id": { + "name": "storage_integrations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_objects": { + "name": "storage_objects", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrationId": { + "name": "integrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectKey": { + "name": "objectKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "objectKeyHash": { + "name": "objectKeyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerObjectId": { + "name": "providerObjectId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadSessionUrl": { + "name": "uploadSessionUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadStatus": { + "name": "uploadStatus", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "contentType": { + "name": "contentType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentLength": { + "name": "contentLength", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "integration_key_hash_idx": { + "name": "integration_key_hash_idx", + "columns": ["integrationId", "objectKeyHash"], + "isUnique": true + }, + "integration_status_idx": { + "name": "integration_status_idx", + "columns": ["integrationId", "uploadStatus"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "storage_objects_integrationId_storage_integrations_id_fk": { + "name": "storage_objects_integrationId_storage_integrations_id_fk", + "tableFrom": "storage_objects", + "tableTo": "storage_integrations", + "columnsFrom": ["integrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "storage_objects_id": { + "name": "storage_objects_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authSessionVersion": { + "name": "authSessionVersion", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": ["identifier"] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "video_edits": { + "name": "video_edits", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceKey": { + "name": "sourceKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editSpec": { + "name": "editSpec", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "video_edits_videoId_videos_id_fk": { + "name": "video_edits_videoId_videos_id_fk", + "tableFrom": "video_edits", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_edits_videoId": { + "name": "video_edits_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "phase_updated_at_video_id_idx": { + "name": "phase_updated_at_video_id_idx", + "columns": ["phase", "updated_at", "video_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'My Video'" + }, + "bucket": { + "name": "bucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageIntegrationId": { + "name": "storageIntegrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"type\":\"MediaConvert\"}')" + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "effectiveCreatedAt": { + "name": "effectiveCreatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )", + "type": "stored" + } + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xStreamInfo": { + "name": "xStreamInfo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstViewEmailSentAt": { + "name": "firstViewEmailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isScreenshot": { + "name": "isScreenshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awsRegion": { + "name": "awsRegion", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "awsBucket": { + "name": "awsBucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoStartTime": { + "name": "videoStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audioStartTime": { + "name": "audioStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobId": { + "name": "jobId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobStatus": { + "name": "jobStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skipProcessing": { + "name": "skipProcessing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "is_public_idx": { + "name": "is_public_idx", + "columns": ["public"], + "isUnique": false + }, + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "storage_integration_id_idx": { + "name": "storage_integration_id_idx", + "columns": ["storageIntegrationId"], + "isUnique": false + }, + "org_owner_folder_idx": { + "name": "org_owner_folder_idx", + "columns": ["orgId", "ownerId", "folderId"], + "isUnique": false + }, + "org_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "videos_storageIntegrationId_storage_integrations_id_fk": { + "name": "videos_storageIntegrationId_storage_integrations_id_fk", + "tableFrom": "videos", + "tableTo": "storage_integrations", + "columnsFrom": ["storageIntegrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "videos_id": { + "name": "videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 98b25051cee..6c7a67c1a6c 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1784402487715, "tag": "0036_premium_master_chief", "breakpoints": true + }, + { + "idx": 37, + "version": "5", + "when": 1785452585612, + "tag": "0037_integration_installations", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 9ba66cc16af..d59bb3455ed 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -256,6 +256,35 @@ export const organizationMembers = mysqlTable( }), ); +export const integrationInstallations = mysqlTable( + "integration_installations", + { + id: nanoId("id").notNull().primaryKey(), + provider: varchar("provider", { length: 64 }).notNull(), + externalId: varchar("externalId", { length: 255 }).notNull(), + displayName: varchar("displayName", { length: 255 }).notNull(), + organizationId: nanoId("organizationId") + .notNull() + .$type(), + installedByUserId: nanoId("installedByUserId") + .notNull() + .$type(), + encryptedCredentials: encryptedText("encryptedCredentials").notNull(), + metadata: json("metadata").notNull().$type>(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + providerExternalIdIndex: uniqueIndex("provider_external_id_idx").on( + table.provider, + table.externalId, + ), + organizationProviderDisplayNameIndex: index( + "organization_provider_display_name_idx", + ).on(table.organizationId, table.provider, table.displayName), + }), +); + export const organizationInvites = mysqlTable( "organization_invites", { @@ -964,6 +993,7 @@ export const usersRelations = relations(users, ({ many, one }) => ({ messengerConversations: many(messengerConversations), messengerMessages: many(messengerMessages), messengerSupportEmails: many(messengerSupportEmails), + integrationInstallations: many(integrationInstallations), })); export const accountsRelations = relations(accounts, ({ one }) => ({ @@ -1023,6 +1053,7 @@ export const organizationsRelations = relations( spaces: many(spaces), s3Buckets: many(s3Buckets), storageIntegrations: many(storageIntegrations), + integrationInstallations: many(integrationInstallations), }), ); @@ -1054,6 +1085,20 @@ export const organizationMembersRelations = relations( }), ); +export const integrationInstallationsRelations = relations( + integrationInstallations, + ({ one }) => ({ + organization: one(organizations, { + fields: [integrationInstallations.organizationId], + references: [organizations.id], + }), + installedByUser: one(users, { + fields: [integrationInstallations.installedByUserId], + references: [users.id], + }), + }), +); + export const organizationInvitesRelations = relations( organizationInvites, ({ one }) => ({ diff --git a/packages/env/server.ts b/packages/env/server.ts index 5af7638eaef..71c46370cec 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -106,6 +106,9 @@ function createServerEnv() { STRIPE_WEBHOOK_SECRET: z.string().optional(), DISCORD_FEEDBACK_WEBHOOK_URL: z.string().optional(), DISCORD_LOGS_WEBHOOK_URL: z.string().optional(), + SLACK_CLIENT_ID: z.string().optional(), + SLACK_CLIENT_SECRET: z.string().optional(), + SLACK_SIGNING_SECRET: z.string().optional(), /// Tinybird analytics TINYBIRD_HOST: z.string().optional(), diff --git a/packages/web-backend/src/Organisations/index.ts b/packages/web-backend/src/Organisations/index.ts index 4fc624fe14b..55bf96d6665 100644 --- a/packages/web-backend/src/Organisations/index.ts +++ b/packages/web-backend/src/Organisations/index.ts @@ -249,6 +249,9 @@ export class Organisations extends Effect.Service()( await tx .delete(Db.organizationInvites) .where(Dz.eq(Db.organizationInvites.organizationId, id)); + await tx + .delete(Db.integrationInstallations) + .where(Dz.eq(Db.integrationInstallations.organizationId, id)); await tx .delete(Db.organizationMembers) .where(Dz.eq(Db.organizationMembers.organizationId, id));