diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index a4db42836b..9511723a4a 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -9,17 +9,18 @@ use tracing::{instrument, trace}; use crate::web_api::{AuthedApiError, ManagerExt}; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MultipartUploadInitiateResponse { + pub upload_id: String, + pub provider: Option, +} + #[instrument(skip(app))] pub async fn upload_multipart_initiate( app: &AppHandle, video_id: &str, -) -> Result { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct Response { - upload_id: String, - } - +) -> Result { let resp = app .authed_api_request("/api/upload/multipart/initiate", |c, url| { c.post(url) @@ -41,10 +42,9 @@ pub async fn upload_multipart_initiate( return Err(format!("api/upload_multipart_initiate/{status}: {error_body}").into()); } - resp.json::() + resp.json::() .await .map_err(|err| format!("api/upload_multipart_initiate/response: {err}").into()) - .map(|data| data.upload_id) } #[instrument(skip(app, upload_id))] diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 7aa875d28b..9e9beda204 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -60,6 +60,51 @@ const NETWORK_RECOVERY_TIMEOUT: Duration = Duration::from_secs(5 * 60); const CONNECTIVITY_PROBE_INITIAL_DELAY: Duration = Duration::from_secs(2); const CONNECTIVITY_PROBE_MAX_DELAY: Duration = Duration::from_secs(30); +fn is_google_drive_resumable_url(url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(url) else { + return false; + }; + url.host_str().is_some_and(|host| { + (host == "googleapis.com" || host.ends_with(".googleapis.com")) + && url.path().starts_with("/upload/drive/") + }) +} + +fn is_google_drive_upload(provider: Option<&str>, upload_id: &str) -> bool { + provider == Some("googleDrive") || is_google_drive_resumable_url(upload_id) +} + +fn with_drive_content_range( + request: reqwest::RequestBuilder, + url: &str, + offset: u64, + size: u64, + total_size: u64, +) -> reqwest::RequestBuilder { + if !is_google_drive_resumable_url(url) || size == 0 { + return request; + } + + let end = offset.saturating_add(size).saturating_sub(1); + request.header( + "Content-Range", + format!("bytes {offset}-{end}/{total_size}"), + ) +} + +fn is_upload_response_accepted( + url: &str, + status: StatusCode, + offset: u64, + size: u64, + total_size: u64, +) -> bool { + status.is_success() + || (is_google_drive_resumable_url(url) + && status == StatusCode::PERMANENT_REDIRECT + && offset.saturating_add(size) < total_size) +} + #[instrument(skip(app, channel, file_path, screenshot_path))] pub async fn upload_video( app: &AppHandle, @@ -72,7 +117,9 @@ pub async fn upload_video( info!("Uploading video {video_id}..."); let start = Instant::now(); - let upload_id = api::upload_multipart_initiate(app, &video_id).await?; + let upload = api::upload_multipart_initiate(app, &video_id).await?; + let is_drive_upload = is_google_drive_upload(upload.provider.as_deref(), &upload.upload_id); + let upload_id = upload.upload_id; let video_fut = async { let failed_chunks: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -84,6 +131,7 @@ pub async fn upload_video( app.clone(), video_id.clone(), upload_id.clone(), + is_drive_upload, from_pending_file_to_chunks(file_path.clone(), None), failed_chunks.clone(), ), @@ -480,7 +528,9 @@ impl InstantMultipartUpload { .map_err(|e| error!("Failed to save recording meta: {e}")) .ok(); - let upload_id = api::upload_multipart_initiate(&app, &video_id).await?; + let upload = api::upload_multipart_initiate(&app, &video_id).await?; + let is_drive_upload = is_google_drive_upload(upload.provider.as_deref(), &upload.upload_id); + let upload_id = upload.upload_id; let failed_chunks: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -491,6 +541,7 @@ impl InstantMultipartUpload { app.clone(), video_id.clone(), upload_id.clone(), + is_drive_upload, from_pending_file_to_chunks(file_path.clone(), realtime_video_done), failed_chunks.clone(), ), @@ -656,6 +707,12 @@ struct SegmentUploadManifest { is_complete: bool, } +impl SegmentUploadManifest { + fn has_video_content(&self) -> bool { + self.video_init_uploaded && !self.video_segments.is_empty() + } +} + struct PresignedUrlCache { urls: tokio::sync::Mutex>, } @@ -1394,6 +1451,23 @@ impl SegmentUploader { .lock() .unwrap_or_else(|e| e.into_inner()) .to_complete_manifest(); + if !final_manifest.has_video_content() { + let error = format!("Segment upload completed without video segments for {video_id}"); + error!(video_id, "Segment upload completed without video segments"); + + if let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir) { + meta.upload = Some(UploadMeta::Failed { + error: error.clone(), + }); + if let Err(err) = meta.save_for_project() { + warn!("Failed to save failed segment upload metadata: {err}"); + } + } + + emit_upload_complete(&app, &video_id); + + return Err(error.into()); + } Self::upload_manifest(&app, &video_id, &final_manifest).await?; { @@ -1633,6 +1707,7 @@ fn multipart_uploader( app: AppHandle, video_id: String, upload_id: String, + is_drive_upload: bool, stream: impl Stream> + Send + 'static, failed_chunks: Arc>>, ) -> impl Stream> + 'static { @@ -1644,6 +1719,11 @@ fn multipart_uploader( stream::once(async move { let use_md5_hashes = app.is_server_url_custom().await; + let max_concurrent_uploads = if is_drive_upload { + 1 + } else { + MAX_CONCURRENT_UPLOADS + }; let first_chunk_presigned_url = Arc::new(Mutex::new(None::<(String, Instant)>)); stream::unfold( @@ -1747,11 +1827,18 @@ fn multipart_uploader( })? .clone(); - let mut req = client + let req = client .put(&presigned_url) .header("Content-Length", size) .timeout(Duration::from_secs(5 * 60)) .body(chunk); + let mut req = with_drive_content_range( + req, + &presigned_url, + offset, + size as u64, + total_size, + ); if let Some(md5_sum) = &md5_sum { req = req.header("Content-MD5", md5_sum); @@ -1784,11 +1871,18 @@ fn multipart_uploader( md5_sum.as_deref(), ) .await?; - let mut retry_req = client + let retry_req = client .put(&retry_url) .header("Content-Length", size) .timeout(Duration::from_secs(5 * 60)) .body(chunk_for_retry); + let mut retry_req = with_drive_content_range( + retry_req, + &retry_url, + offset, + size as u64, + total_size, + ); if let Some(md5_sum) = &md5_sum { retry_req = retry_req.header("Content-MD5", md5_sum); @@ -1821,7 +1915,13 @@ fn multipart_uploader( .and_then(|etag| etag.to_str().ok()) .map(|v| v.trim_matches('"').to_string()); - match !resp.status().is_success() { + match !is_upload_response_accepted( + &presigned_url, + resp.status(), + offset, + size as u64, + total_size, + ) { true => Err(format!( "uploader/part/{part_number}/error: {}", resp.text().await.unwrap_or_default() @@ -1831,12 +1931,21 @@ fn multipart_uploader( trace!("Completed upload of part {part_number}"); - Ok::<_, AuthedApiError>(UploadedPart { - etag: etag.ok_or_else(|| { - format!( - "uploader/part/{part_number}/error: ETag header not found" + let etag = match etag { + Some(etag) => etag, + None if is_google_drive_resumable_url(&presigned_url) => { + format!("drive-{part_number}") + } + None => { + return Err(format!( + "uploader/part/{part_number}/missing_etag" ) - })?, + .into()); + } + }; + + Ok::<_, AuthedApiError>(UploadedPart { + etag, part_number, size, total_size, @@ -1874,7 +1983,7 @@ fn multipart_uploader( } }, ) - .buffered(MAX_CONCURRENT_UPLOADS) + .buffered(max_concurrent_uploads) .filter_map(|item| async { item }) .boxed() }) @@ -1948,11 +2057,18 @@ async fn retry_failed_chunks( .map_err(|err| format!("retry/part/{}/client: {err:?}", failed.part_number))? .clone(); - let mut req = client + let req = client .put(&presigned_url) .header("Content-Length", size) .timeout(Duration::from_secs(5 * 60)) .body(chunk); + let mut req = with_drive_content_range( + req, + &presigned_url, + failed.offset, + size as u64, + failed.total_size, + ); if let Some(md5_sum) = &md5_sum { req = req.header("Content-MD5", md5_sum); @@ -1990,11 +2106,18 @@ async fn retry_failed_chunks( md5_sum.as_deref(), ) .await?; - let mut retry_req = client + let retry_req = client .put(&retry_url) .header("Content-Length", size) .timeout(Duration::from_secs(5 * 60)) .body(chunk_for_retry); + let mut retry_req = with_drive_content_range( + retry_req, + &retry_url, + failed.offset, + size as u64, + failed.total_size, + ); if let Some(md5_sum) = &md5_sum { retry_req = retry_req.header("Content-MD5", md5_sum); } @@ -2025,7 +2148,13 @@ async fn retry_failed_chunks( .and_then(|etag| etag.to_str().ok()) .map(|v| v.trim_matches('"').to_string()); - if !resp.status().is_success() { + if !is_upload_response_accepted( + &presigned_url, + resp.status(), + failed.offset, + size as u64, + failed.total_size, + ) { return Err(format!( "retry/part/{}/error: {}", failed.part_number, @@ -2039,13 +2168,18 @@ async fn retry_failed_chunks( "Successfully retried chunk upload" ); + let etag = match etag { + Some(etag) => etag, + None if is_google_drive_resumable_url(&presigned_url) => { + format!("drive-{}", failed.part_number) + } + None => { + return Err(format!("retry/part/{}/missing_etag", failed.part_number).into()); + } + }; + retry_parts.push(UploadedPart { - etag: etag.ok_or_else(|| { - format!( - "retry/part/{}/error: ETag header not found", - failed.part_number - ) - })?, + etag, part_number: failed.part_number, size, total_size: failed.total_size, @@ -2112,13 +2246,15 @@ pub async fn singlepart_uploader( ) -> Result<(), AuthedApiError> { let presigned_url = api::upload_signed(&app, request).await?; - let resp = app + let request = app .state::() .as_ref() .map_err(|err| format!("singlepart_uploader/client: {err:?}"))? .put(&presigned_url) .header("Content-Length", total_size) - .body(reqwest::Body::wrap_stream(stream)) + .body(reqwest::Body::wrap_stream(stream)); + + let resp = with_drive_content_range(request, &presigned_url, 0, total_size, total_size) .send() .await .map_err(|err| format!("singlepart_uploader/error: {err:?}"))?; @@ -2759,6 +2895,18 @@ mod tests { let complete = state.to_complete_manifest(); assert!(complete.is_complete); assert_eq!(complete.video_segments.len(), 2); + assert!(complete.has_video_content()); + } + + #[tokio::test] + async fn upload_state_without_video_segments_has_no_video_content() { + let mut state = SegmentUploadState::new(); + state.video_init_uploaded = true; + state.audio_init_uploaded = true; + + let complete = state.to_complete_manifest(); + assert!(complete.is_complete); + assert!(!complete.has_video_content()); } #[tokio::test] diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-tauri/src/web_api.rs index 01b52fe037..7bd522040a 100644 --- a/apps/desktop/src-tauri/src/web_api.rs +++ b/apps/desktop/src-tauri/src/web_api.rs @@ -51,7 +51,9 @@ impl From for AuthedApiError { } fn apply_env_headers(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - let mut req = req.header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")); + let mut req = req + .header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")) + .header("X-Cap-Desktop-Features", "googleDriveUpload"); if let Ok(s) = std::env::var("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") { req = req.header("x-vercel-protection-bypass", s); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 748f758119..7bb1782121 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -54,6 +54,12 @@ const SettingsIntegrationsPage = lazy( const SettingsS3ConfigPage = lazy( () => import("./routes/(window-chrome)/settings/integrations/s3-config"), ); +const SettingsGoogleDriveConfigPage = lazy( + () => + import( + "./routes/(window-chrome)/settings/integrations/google-drive-config" + ), +); const OnboardingPage = lazy( () => import("./routes/(window-chrome)/onboarding"), ); @@ -173,6 +179,10 @@ function Inner() { path="/integrations/s3-config" component={SettingsS3ConfigPage} /> + diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/config-header.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/config-header.tsx new file mode 100644 index 0000000000..f46f593cf2 --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/config-header.tsx @@ -0,0 +1,22 @@ +import { Button } from "@cap/ui-solid"; +import { useNavigate } from "@solidjs/router"; +import IconLucideArrowLeft from "~icons/lucide/arrow-left"; + +export function IntegrationConfigHeader(props: { title: string }) { + const navigate = useNavigate(); + + return ( +
+ +

{props.title}

+
+ ); +} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx new file mode 100644 index 0000000000..7d75a3c946 --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx @@ -0,0 +1,417 @@ +import { Button } from "@cap/ui-solid"; +import { useMutation } from "@tanstack/solid-query"; +import { createResource, createSignal, Show, Suspense } from "solid-js"; +import { commands } from "~/utils/tauri"; +import { apiClient, protectedHeaders } from "~/utils/web-api"; +import { IntegrationConfigHeader } from "./config-header"; + +const byteUnits = ["B", "KB", "MB", "GB", "TB", "PB"] as const; +const googleDriveConnectionPollIntervalMs = 1500; +const googleDriveConnectionPollTimeoutMs = 120000; + +const formatBytes = (value?: string | null) => { + if (!value) return null; + + const bytes = Number(value); + if (!Number.isFinite(bytes)) return null; + if (bytes === 0) return "0 B"; + + let size = bytes; + let unitIndex = 0; + while (size >= 1024 && unitIndex < byteUnits.length - 1) { + size /= 1024; + unitIndex += 1; + } + + const decimals = size >= 10 || unitIndex === 0 ? 0 : 1; + return `${size.toFixed(decimals)} ${byteUnits[unitIndex]}`; +}; + +const formatTimestamp = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +}; + +const wait = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const fetchStorageIntegrations = async (refreshStorageQuota = false) => { + const response = await apiClient.desktop.getStorageIntegrations({ + query: refreshStorageQuota ? { refreshStorageQuota: true } : undefined, + headers: await protectedHeaders(), + }); + + if (response.status !== 200) + throw new Error("Failed to fetch storage integrations"); + + return response.body; +}; + +const fetchS3Config = async () => { + const response = await apiClient.desktop.getS3Config({ + headers: await protectedHeaders(), + }); + + if (response.status !== 200) throw new Error("Failed to fetch S3 config"); + + return response.body.config; +}; + +export default function GoogleDriveConfigPage() { + const [isWaitingForConnection, setIsWaitingForConnection] = + createSignal(false); + const [isRefreshing, setIsRefreshing] = createSignal(false); + const [storage, { mutate: setStorage }] = createResource(() => + fetchStorageIntegrations(), + ); + + const googleDrive = () => storage()?.googleDrive; + const storageQuota = () => googleDrive()?.storageQuota ?? null; + const isConnected = () => googleDrive()?.connected === true; + const isActive = () => storage()?.activeProvider === "googleDrive"; + + const [s3Config, { mutate: setS3Config }] = createResource(fetchS3Config); + + const hasS3Config = () => { + const config = s3Config(); + return !!config?.accessKeyId && !!config.bucketName; + }; + + const updateStorage = async (refreshStorageQuota = false) => { + const nextStorage = await fetchStorageIntegrations(refreshStorageQuota); + setStorage(nextStorage); + return nextStorage; + }; + + const updateS3Config = async () => { + const nextConfig = await fetchS3Config(); + setS3Config(nextConfig); + return nextConfig; + }; + + const refetch = async () => { + setIsRefreshing(true); + await Promise.all([updateStorage(true), updateS3Config()]).finally(() => { + setIsRefreshing(false); + }); + }; + + const quotaUsageLabel = () => { + const quota = storageQuota(); + const usage = formatBytes(quota?.usage); + if (!quota || !usage) return null; + + const limit = formatBytes(quota.limit); + return limit ? `${usage} of ${limit} used` : `${usage} used`; + }; + + const quotaUsagePercent = () => { + const quota = storageQuota(); + if (!quota?.limit || !quota.usage) return null; + + const limit = Number(quota.limit); + const usage = Number(quota.usage); + if (!Number.isFinite(limit) || !Number.isFinite(usage) || limit <= 0) + return null; + + return Math.min(Math.max((usage / limit) * 100, 0), 100); + }; + + const quotaTimestampLabel = () => { + const quota = storageQuota(); + if (!quota) return null; + + const timestamp = formatTimestamp(quota.fetchedAt); + if (!timestamp) return null; + + return `${quota.stale ? "Cached" : "Updated"} ${timestamp}`; + }; + + const waitForGoogleDriveConnection = async () => { + setIsWaitingForConnection(true); + try { + const timeoutAt = Date.now() + googleDriveConnectionPollTimeoutMs; + while (Date.now() < timeoutAt) { + await wait(googleDriveConnectionPollIntervalMs); + const nextStorage = await updateStorage(); + if (nextStorage?.googleDrive.connected) { + await updateS3Config(); + return; + } + } + await commands.globalMessageDialog( + "Finish connecting Google Drive in your browser, then return here and refresh.", + ); + } finally { + setIsWaitingForConnection(false); + } + }; + + const connect = useMutation(() => ({ + mutationFn: async () => { + const response = await apiClient.desktop.connectGoogleDriveStorage({ + body: {}, + headers: await protectedHeaders(), + }); + + if (response.status === 403) { + await commands.showWindow("Upgrade"); + return null; + } + + if (response.status !== 200) + throw new Error("Failed to start Google Drive connection"); + + await commands.openExternalLink(response.body.url); + return response.body; + }, + onSuccess: (body) => { + if (!body) return; + waitForGoogleDriveConnection().catch((error) => { + console.error("Failed to wait for Google Drive connection:", error); + }); + }, + })); + + const testConnection = useMutation(() => ({ + mutationFn: async () => { + const response = await apiClient.desktop.testGoogleDriveStorage({ + body: {}, + headers: await protectedHeaders(), + }); + + if (response.status !== 200) + throw new Error("Google Drive connection test failed"); + + return response.body; + }, + onSuccess: async (body) => { + await commands.globalMessageDialog( + body.email + ? `Google Drive connection is working for ${body.email}` + : "Google Drive connection is working", + ); + }, + })); + + const setActive = useMutation(() => ({ + mutationFn: async (provider: "s3" | "googleDrive") => { + const response = await apiClient.desktop.setActiveStorageProvider({ + body: { provider }, + headers: await protectedHeaders(), + }); + + if (response.status !== 200) + throw new Error("Failed to update active storage provider"); + + return response.body; + }, + onSuccess: async () => { + await refetch(); + }, + })); + + const disconnect = useMutation(() => ({ + mutationFn: async () => { + const response = await apiClient.desktop.disconnectGoogleDriveStorage({ + headers: await protectedHeaders(), + }); + + if (response.status !== 200) + throw new Error("Failed to disconnect Google Drive"); + + return response.body; + }, + onSuccess: async () => { + await refetch(); + await commands.globalMessageDialog("Google Drive disconnected"); + }, + })); + + const busy = () => + storage.loading || + s3Config.loading || + isRefreshing() || + connect.isPending || + isWaitingForConnection() || + testConnection.isPending || + setActive.isPending || + disconnect.isPending; + + return ( +
+ +
+
+ + +
+ } + > +
+
+

+ Google Drive stores new uploads in a private Cap folder in + your Drive. Existing Cap-hosted and S3 videos keep using their + current storage. +

+
+ +
+
+
+

+ {isConnected() + ? googleDrive()?.displayName + : "Google Drive"} +

+

+ {isConnected() + ? isActive() + ? "Active for new uploads" + : "Connected but not active" + : "Not connected"} +

+
+ +
+ + connect.mutate()} + > + {isWaitingForConnection() + ? "Waiting..." + : connect.isPending + ? "Opening..." + : "Connect Google Drive"} + + } + > + +
+
+
+

+ Storage +

+ + {(label) => ( +

{label()}

+ )} +
+
+ + {(label) => ( +

+ {label()} +

+ )} +
+
+ +
+
+
+ +
+ + {(remaining) => ( + <> +

Remaining

+

+ {remaining()} +

+ + )} +
+ + {(usageInDrive) => ( + <> +

Drive files

+

+ {usageInDrive()} +

+ + )} +
+ + {(usageInDriveTrash) => ( + <> +

Trash

+

+ {usageInDriveTrash()} +

+ + )} +
+
+
+
+
+ + + + + + +
+ +
+
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx index 491e2752bd..41e94bfb50 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx @@ -7,6 +7,40 @@ import "@total-typescript/ts-reset/filter-boolean"; import { authStore } from "~/store"; import { commands } from "~/utils/tauri"; +const GoogleDriveIcon = (props: { class?: string }) => ( + +); + export default function AppsTab() { const navigate = useNavigate(); const auth = authStore.createQuery(); @@ -18,6 +52,14 @@ export default function AppsTab() { }); const apps = [ + { + name: "Google Drive", + description: + "Connect Google Drive for new shareable link uploads. Cap stores new videos in a private Cap folder in your Drive and continues serving them through Cap after normal access checks.", + icon: GoogleDriveIcon, + url: "/settings/integrations/google-drive-config", + pro: true, + }, { name: "S3 Config", description: diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config.tsx index 70a6849d3e..36e1fe44e3 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/s3-config.tsx @@ -5,6 +5,7 @@ import { createResource, Suspense } from "solid-js"; import { Input } from "~/routes/editor/ui"; import { commands } from "~/utils/tauri"; import { apiClient, protectedHeaders } from "~/utils/web-api"; +import { IntegrationConfigHeader } from "./config-header"; interface S3Config { provider: string; @@ -74,7 +75,7 @@ export default function S3ConfigPage() { const testConfig = useMutation(() => ({ mutationFn: async (config: S3Config) => { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5500); // 5.5s timeout (slightly longer than backend) + const timeoutId = setTimeout(() => controller.abort(), 5500); try { const response = await apiClient.desktop.testS3Config({ @@ -144,6 +145,7 @@ export default function S3ConfigPage() { return (
+
= { + "Content-Type": contentType, + "Content-Length": contentLength.toString(), + }; + if (isGoogleDriveResumableUrl(presignedUrl) && contentLength > 0) { + headers["Content-Range"] = + `bytes 0-${contentLength - 1}/${contentLength}`; + } + const response = await fetch(presignedUrl, { method: "PUT", - headers: { - "Content-Type": contentType, - "Content-Length": contentLength.toString(), - }, + headers, body: bodyFactory(), signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS), }); @@ -689,7 +699,7 @@ async function uploadWithRetry( } const responseError = new Error( - `S3 upload failed: ${response.status} ${response.statusText}`, + `Storage upload failed: ${response.status} ${response.statusText}`, ); if ( diff --git a/apps/web/__tests__/integration/transcribe.test.ts b/apps/web/__tests__/integration/transcribe.test.ts index 9cf8afe418..d00d638cfb 100644 --- a/apps/web/__tests__/integration/transcribe.test.ts +++ b/apps/web/__tests__/integration/transcribe.test.ts @@ -29,22 +29,23 @@ let mockUploadQueryResult: unknown[] = []; vi.mock("@cap/database", () => ({ db: () => ({ select: () => ({ - from: (table: unknown) => - table === schemaMocks.videoUploads - ? { - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(mockUploadQueryResult), - }), - } - : { - leftJoin: () => ({ - leftJoin: () => ({ - where: vi - .fn() - .mockImplementation(() => Promise.resolve(mockQueryResult)), - }), - }), - }, + from: (table: unknown) => { + if (table === schemaMocks.videoUploads) { + return { + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(mockUploadQueryResult), + }), + }; + } + + const query = { + leftJoin: vi.fn(() => query), + where: vi + .fn() + .mockImplementation(() => Promise.resolve(mockQueryResult)), + }; + return query; + }, }), update: () => ({ set: () => ({ diff --git a/apps/web/__tests__/unit/instant-recording-uploader.test.ts b/apps/web/__tests__/unit/instant-recording-uploader.test.ts index dc1eaf0d81..7c2d87bb80 100644 --- a/apps/web/__tests__/unit/instant-recording-uploader.test.ts +++ b/apps/web/__tests__/unit/instant-recording-uploader.test.ts @@ -6,11 +6,12 @@ import { import type { VideoId } from "@/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-types"; const STREAMED_PART_BYTES = 5 * 1024 * 1024 + 128; +const DRIVE_PART_BYTES = 16 * 1024 * 1024; const OVERFLOW_PART_BYTES = 129 * 1024 * 1024; const FINALIZED_BLOB_BYTES = 129 * 1024 * 1024; type UploadOutcome = - | { type: "success"; etag: string } + | { type: "success"; etag?: string; status?: number } | { type: "network-error" } | { type: "pending" }; @@ -73,9 +74,9 @@ class MockXMLHttpRequest { } this.completed = true; - this.status = 200; - this.statusText = "OK"; - this.headers.set("etag", `"${outcome.etag}"`); + this.status = outcome.status ?? 200; + this.statusText = this.status === 308 ? "Resume Incomplete" : "OK"; + if (outcome.etag) this.headers.set("etag", `"${outcome.etag}"`); this.onload?.(); } @@ -231,7 +232,149 @@ describe("InstantRecordingUploader", () => { contentType: "video/webm;codecs=vp9,opus", subpath: "raw-upload.webm", }), - ).resolves.toBe("upload-123"); + ).resolves.toEqual({ uploadId: "upload-123", provider: "s3" }); + }); + + it("uses aligned sequential resumable parts for Google Drive uploads", async () => { + const totalBytes = DRIVE_PART_BYTES + 4 * 1024 * 1024; + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = init?.body ? JSON.parse(init.body as string) : null; + + if (url === "/api/upload/multipart/presign-part") { + return makeJsonResponse({ + presignedUrl: `https://www.googleapis.com/upload/drive/v3/files/session-${body.partNumber}`, + provider: "googleDrive", + }); + } + + if (url === "/api/upload/multipart/complete") { + expect(body.parts).toEqual([ + expect.objectContaining({ + partNumber: 1, + etag: "drive-1", + size: DRIVE_PART_BYTES, + }), + expect.objectContaining({ + partNumber: 2, + etag: "drive-2", + size: 4 * 1024 * 1024, + }), + ]); + return makeJsonResponse({ success: true }); + } + + throw new Error(`Unexpected fetch call: ${url}`); + }, + ); + + vi.stubGlobal("fetch", fetchMock); + MockXMLHttpRequest.setOutcomes([ + { type: "success", status: 308 }, + { type: "success", status: 200 }, + ]); + + const uploader = new InstantRecordingUploader({ + videoId, + uploadId: "drive-session", + provider: "googleDrive", + mimeType: "video/webm", + subpath: "raw-upload.webm", + setUploadStatus: vi.fn(), + sendProgressUpdate: vi.fn().mockResolvedValue(undefined), + }); + + uploader.handleChunk( + makeBlob(10 * 1024 * 1024, "video/webm"), + 10 * 1024 * 1024, + ); + uploader.handleChunk(makeBlob(10 * 1024 * 1024, "video/webm"), totalBytes); + + await uploader.finalize({ + finalBlob: makeBlob(totalBytes, "video/webm"), + durationSeconds: 20, + subpath: "raw-upload.webm", + }); + + expect(MockXMLHttpRequest.recordedHeaders[0]?.get("content-range")).toBe( + `bytes 0-${DRIVE_PART_BYTES - 1}/*`, + ); + expect(MockXMLHttpRequest.recordedHeaders[1]?.get("content-range")).toBe( + `bytes ${DRIVE_PART_BYTES}-${totalBytes - 1}/${totalBytes}`, + ); + expect(MockXMLHttpRequest.recordedHeaders[0]?.get("content-type")).toBe( + "video/webm", + ); + }); + + it("finalizes Google Drive streamed chunks with a concrete total byte count", async () => { + const totalBytes = DRIVE_PART_BYTES + 4 * 1024 * 1024; + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = init?.body ? JSON.parse(init.body as string) : null; + + if (url === "/api/upload/multipart/presign-part") { + return makeJsonResponse({ + presignedUrl: `https://www.googleapis.com/upload/drive/v3/files/session-${body.partNumber}`, + provider: "googleDrive", + }); + } + + if (url === "/api/upload/multipart/complete") { + expect(body.parts).toEqual([ + expect.objectContaining({ + partNumber: 1, + etag: "drive-1", + size: DRIVE_PART_BYTES, + }), + expect.objectContaining({ + partNumber: 2, + etag: "drive-2", + size: 4 * 1024 * 1024, + }), + ]); + return makeJsonResponse({ success: true }); + } + + throw new Error(`Unexpected fetch call: ${url}`); + }, + ); + + vi.stubGlobal("fetch", fetchMock); + MockXMLHttpRequest.setOutcomes([ + { type: "success", status: 308 }, + { type: "success", status: 200 }, + ]); + + const uploader = new InstantRecordingUploader({ + videoId, + uploadId: "drive-session", + provider: "googleDrive", + mimeType: "video/webm", + subpath: "raw-upload.webm", + setUploadStatus: vi.fn(), + sendProgressUpdate: vi.fn().mockResolvedValue(undefined), + }); + + uploader.handleChunk( + makeBlob(10 * 1024 * 1024, "video/webm"), + 10 * 1024 * 1024, + ); + uploader.handleChunk(makeBlob(10 * 1024 * 1024, "video/webm"), totalBytes); + + await uploader.finalize({ + durationSeconds: 20, + subpath: "raw-upload.webm", + }); + + expect(MockXMLHttpRequest.recordedHeaders[0]?.get("content-range")).toBe( + `bytes 0-${DRIVE_PART_BYTES - 1}/*`, + ); + expect(MockXMLHttpRequest.recordedHeaders[1]?.get("content-range")).toBe( + `bytes ${DRIVE_PART_BYTES}-${totalBytes - 1}/${totalBytes}`, + ); }); it("completes multipart uploads with parts ordered by part number", async () => { diff --git a/apps/web/__tests__/unit/loom-import.test.ts b/apps/web/__tests__/unit/loom-import.test.ts index e7785fbb5a..168656cb1a 100644 --- a/apps/web/__tests__/unit/loom-import.test.ts +++ b/apps/web/__tests__/unit/loom-import.test.ts @@ -1,9 +1,11 @@ +import { Effect, Option } from "effect"; import { beforeEach, describe, expect, it, vi } from "vitest"; const whereMock = vi.fn(); const valuesMock = vi.fn(); const startMock = vi.fn(); const revalidatePathMock = vi.fn(); +const storageGetWritableAccessForUserMock = vi.hoisted(() => vi.fn()); const mockDb = { select: vi.fn(() => mockDb), @@ -65,6 +67,12 @@ vi.mock("@cap/utils", () => ({ userIsPro: vi.fn(() => true), })); +vi.mock("@cap/web-backend", () => ({ + Storage: { + getWritableAccessForUser: storageGetWritableAccessForUserMock, + }, +})); + vi.mock("@cap/web-domain", () => ({ Video: { VideoId: { @@ -82,6 +90,11 @@ vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock, })); +vi.mock("@/lib/server", async () => { + const { Effect } = await import("effect"); + return { runPromise: Effect.runPromise }; +}); + vi.mock("workflow/api", () => ({ start: startMock, })); @@ -107,6 +120,12 @@ describe("importFromLoom", () => { valuesMock.mockResolvedValue(undefined); whereMock.mockResolvedValue([]); startMock.mockResolvedValue(undefined); + storageGetWritableAccessForUserMock.mockReturnValue( + Effect.succeed({ + bucketId: Option.some("bucket-1"), + storageIntegrationId: Option.none(), + }), + ); mockGetCurrentUser.mockResolvedValue({ id: "user-123", }); @@ -137,8 +156,7 @@ describe("importFromLoom", () => { it("removes a stale Loom row and recreates it with the Cap video id", async () => { whereMock .mockResolvedValueOnce([{ importedVideoId: "stale-row", videoId: null }]) - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce([{ id: "bucket-1" }]); + .mockResolvedValueOnce(undefined); const fetchMock = vi.mocked(fetch); fetchMock.mockImplementation(async (input) => { diff --git a/apps/web/__tests__/unit/videos-policy.test.ts b/apps/web/__tests__/unit/videos-policy.test.ts index 2e4d0a71a9..bbf4e9a064 100644 --- a/apps/web/__tests__/unit/videos-policy.test.ts +++ b/apps/web/__tests__/unit/videos-policy.test.ts @@ -29,6 +29,7 @@ function makeVideo( source: { type: "desktopMP4" }, metadata: Option.none(), bucketId: Option.none(), + storageIntegrationId: Option.none(), folderId: Option.none(), transcriptionStatus: Option.none(), width: Option.none(), diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 9e435651c9..90ea4ab967 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -4,19 +4,17 @@ import { randomUUID } from "node:crypto"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; -import { - importedVideos, - s3Buckets, - videos, - videoUploads, -} from "@cap/database/schema"; +import { importedVideos, videos, videoUploads } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub, userIsPro } from "@cap/utils"; +import { Storage } from "@cap/web-backend"; import type { Organisation } from "@cap/web-domain"; import { Video } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; +import { Option } from "effect"; import { revalidatePath } from "next/cache"; import { start } from "workflow/api"; +import { runPromise } from "@/lib/server"; import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; interface LoomUrlResponse { @@ -297,10 +295,9 @@ export async function importFromLoom({ fetchLoomOEmbed(loomVideoId), ]); - const [customBucket] = await db() - .select() - .from(s3Buckets) - .where(eq(s3Buckets.ownerId, user.id)); + const writable = await Storage.getWritableAccessForUser(user.id).pipe( + runPromise, + ); const videoId = Video.VideoId.make(nanoId()); const name = @@ -315,7 +312,8 @@ export async function importFromLoom({ ownerId: user.id, orgId, source: { type: "webMP4" as const }, - bucket: customBucket?.id, + bucket: Option.getOrNull(writable.bucketId), + storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, ...(oembedMeta?.duration ? { duration: oembedMeta.duration } : {}), ...(oembedMeta?.width ? { width: oembedMeta.width } : {}), @@ -353,7 +351,7 @@ export async function importFromLoom({ videoId, userId: user.id, rawFileKey, - bucketId: customBucket?.id ?? null, + bucketId: Option.getOrNull(writable.bucketId), loomDownloadUrl: downloadUrl, loomVideoId, }, diff --git a/apps/web/actions/video/create-for-processing.ts b/apps/web/actions/video/create-for-processing.ts index a2a6776f69..3bcc07857b 100644 --- a/apps/web/actions/video/create-for-processing.ts +++ b/apps/web/actions/video/create-for-processing.ts @@ -3,18 +3,17 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; -import { s3Buckets, videos, videoUploads } from "@cap/database/schema"; +import { videos, videoUploads } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub, userIsPro } from "@cap/utils"; -import { S3Buckets } from "@cap/web-backend"; +import { Storage as StorageService } from "@cap/web-backend"; import { type Folder, type Organisation, - S3Bucket, + type Storage, Video, } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Option } from "effect"; import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; @@ -22,10 +21,12 @@ export interface CreateForProcessingResult { id: Video.VideoId; rawFileKey: string; bucketId: string | null; + storageIntegrationId: string | null; + uploadTarget: Storage.UploadTarget; presignedPostData: { url: string; fields: Record; - }; + } | null; } export async function createVideoForServerProcessing({ @@ -47,11 +48,6 @@ export async function createVideoForServerProcessing({ throw new Error("upgrade_required"); } - const [customBucket] = await db() - .select() - .from(s3Buckets) - .where(eq(s3Buckets.ownerId, user.id)); - const videoId = Video.VideoId.make(nanoId()); const date = new Date(); @@ -59,6 +55,21 @@ export async function createVideoForServerProcessing({ month: "long", })} ${date.getFullYear()}`; + const rawFileKey = `${user.id}/${videoId}/raw-upload.mp4`; + + const uploadResult = await StorageService.createUploadTargetForUser( + user.id, + rawFileKey, + { + contentType: "video/mp4", + fields: { + "x-amz-meta-userid": user.id, + "x-amz-meta-duration": duration?.toString() ?? "", + "x-amz-meta-resolution": resolution ?? "", + }, + }, + ).pipe(runPromise); + await db() .insert(videos) .values({ @@ -67,7 +78,8 @@ export async function createVideoForServerProcessing({ ownerId: user.id, orgId, source: { type: "webMP4" as const }, - bucket: customBucket?.id, + bucket: Option.getOrNull(uploadResult.bucketId), + storageIntegrationId: Option.getOrNull(uploadResult.storageIntegrationId), public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, ...(folderId ? { folderId } : {}), }); @@ -78,26 +90,6 @@ export async function createVideoForServerProcessing({ processingProgress: 0, }); - const rawFileKey = `${user.id}/${videoId}/raw-upload.mp4`; - - const bucketIdOption = Option.fromNullable(customBucket?.id).pipe( - Option.map((id) => S3Bucket.S3BucketId.make(id)), - ); - - const presignedPostData = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(bucketIdOption); - - return yield* bucket.getPresignedPostUrl(rawFileKey, { - Fields: { - "Content-Type": "video/mp4", - "x-amz-meta-userid": user.id, - "x-amz-meta-duration": duration?.toString() ?? "", - "x-amz-meta-resolution": resolution ?? "", - }, - Expires: 3600, - }); - }).pipe(runPromise); - if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { await dub() .links.create({ @@ -117,7 +109,15 @@ export async function createVideoForServerProcessing({ return { id: videoId, rawFileKey, - bucketId: customBucket?.id ?? null, - presignedPostData, + bucketId: Option.getOrNull(uploadResult.bucketId), + storageIntegrationId: Option.getOrNull(uploadResult.storageIntegrationId), + uploadTarget: uploadResult.upload, + presignedPostData: + uploadResult.upload.type === "s3Post" + ? { + url: uploadResult.upload.url, + fields: uploadResult.upload.fields, + } + : null, }; } diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 040a5aca2f..a61e035e04 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -3,14 +3,14 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; -import { s3Buckets, videos, videoUploads } from "@cap/database/schema"; +import { videos, videoUploads } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub, userIsPro } from "@cap/utils"; -import { S3Buckets } from "@cap/web-backend"; +import { Storage as StorageService } from "@cap/web-backend"; import { type Folder, type Organisation, - S3Bucket, + type User, Video, } from "@cap/web-domain"; import { eq } from "drizzle-orm"; @@ -27,7 +27,7 @@ async function getVideoUploadPresignedUrl({ resolution, videoCodec, audioCodec, - bucketId, + video, userId, }: { fileKey: string; @@ -35,14 +35,10 @@ async function getVideoUploadPresignedUrl({ resolution?: string; videoCodec?: string; audioCodec?: string; - bucketId: string | undefined; - userId: string; + video?: Video.Video; + userId: User.UserId; }) { try { - const bucketIdOption = Option.fromNullable(bucketId).pipe( - Option.map((id) => S3Bucket.S3BucketId.make(id)), - ); - const contentType = fileKey.endsWith(".aac") ? "audio/aac" : fileKey.endsWith(".webm") @@ -56,7 +52,6 @@ async function getVideoUploadPresignedUrl({ : "video/mp2t"; const Fields = { - "Content-Type": contentType, "x-amz-meta-userid": userId, "x-amz-meta-duration": duration ?? "", "x-amz-meta-resolution": resolution ?? "", @@ -64,16 +59,36 @@ async function getVideoUploadPresignedUrl({ "x-amz-meta-audiocodec": audioCodec ?? "", }; - const presignedPostData = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(bucketIdOption); + const result = await Effect.gen(function* () { + if (video) { + const upload = yield* StorageService.createUploadTargetForVideo( + video, + fileKey, + { + contentType, + fields: Fields, + }, + ); + return { + upload, + bucketId: video.bucketId, + storageIntegrationId: video.storageIntegrationId, + }; + } - return yield* bucket.getPresignedPostUrl(fileKey, { - Fields, - Expires: 1800, + return yield* StorageService.createUploadTargetForUser(userId, fileKey, { + contentType, + fields: Fields, }); }).pipe(runPromise); - return { presignedPostData }; + return { + ...result, + presignedPostData: + result.upload.type === "s3Post" + ? { url: result.upload.url, fields: result.upload.fields } + : null, + }; } catch (error) { console.error("Error getting presigned URL:", error); throw new Error( @@ -114,11 +129,6 @@ export async function createVideoAndGetUploadUrl({ if (!userIsPro(user) && duration && duration > 300) throw new Error("upgrade_required"); - const [customBucket] = await db() - .select() - .from(s3Buckets) - .where(eq(s3Buckets.ownerId, user.id)); - const date = new Date(); const formattedDate = `${date.getDate()} ${date.toLocaleString("default", { month: "long", @@ -131,28 +141,52 @@ export async function createVideoAndGetUploadUrl({ .where(eq(videos.id, videoId)); if (existingVideo) { + if (existingVideo.ownerId !== user.id) throw new Error("Forbidden"); + + const existingVideoDomain = Video.Video.decodeSync({ + ...existingVideo, + bucketId: existingVideo.bucket, + storageIntegrationId: existingVideo.storageIntegrationId, + createdAt: existingVideo.createdAt.toISOString(), + updatedAt: existingVideo.updatedAt.toISOString(), + metadata: existingVideo.metadata, + }); const fileKey = `${user.id}/${videoId}/${ isScreenshot ? "screenshot/screen-capture.jpg" : "result.mp4" }`; - const { presignedPostData } = await getVideoUploadPresignedUrl({ + const { presignedPostData, upload } = await getVideoUploadPresignedUrl({ fileKey, duration: duration?.toString(), resolution, videoCodec, audioCodec, - bucketId: existingVideo.bucket ?? customBucket?.id, + video: existingVideoDomain, userId: user.id, }); return { id: existingVideo.id, presignedPostData, + uploadTarget: upload, }; } } const idToUse = Video.VideoId.make(videoId || nanoId()); + const fileKey = `${user.id}/${idToUse}/${ + isScreenshot ? "screenshot/screen-capture.jpg" : "result.mp4" + }`; + const { presignedPostData, upload, bucketId, storageIntegrationId } = + await getVideoUploadPresignedUrl({ + fileKey, + duration: duration?.toString(), + resolution, + videoCodec, + audioCodec, + userId: user.id, + }); + const videoData = { id: idToUse, name: `Cap ${ @@ -162,7 +196,8 @@ export async function createVideoAndGetUploadUrl({ orgId, source: { type: "webMP4" as const }, isScreenshot, - bucket: customBucket?.id, + bucket: Option.getOrNull(bucketId), + storageIntegrationId: Option.getOrNull(storageIntegrationId), public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, ...(folderId ? { folderId } : {}), }; @@ -174,19 +209,6 @@ export async function createVideoAndGetUploadUrl({ videoId: idToUse, }); - const fileKey = `${user.id}/${idToUse}/${ - isScreenshot ? "screenshot/screen-capture.jpg" : "result.mp4" - }`; - const { presignedPostData } = await getVideoUploadPresignedUrl({ - fileKey, - duration: duration?.toString(), - resolution, - videoCodec, - audioCodec, - bucketId: customBucket?.id, - userId: user.id, - }); - if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { await dub() .links.create({ @@ -206,6 +228,7 @@ export async function createVideoAndGetUploadUrl({ return { id: idToUse, presignedPostData, + uploadTarget: upload, }; } catch (error) { console.error("Error creating video and getting upload URL:", error); @@ -225,25 +248,27 @@ export async function deleteVideoResultFile({ if (!user) throw new Error("Unauthorized"); const [video] = await db() - .select({ - id: videos.id, - ownerId: videos.ownerId, - bucketId: videos.bucket, - }) + .select() .from(videos) .where(eq(videos.id, videoId)); if (!video) throw new Error("Video not found"); if (video.ownerId !== user.id) throw new Error("Forbidden"); - const bucketIdOption = Option.fromNullable(video.bucketId).pipe( - Option.map((id) => S3Bucket.S3BucketId.make(id)), - ); + const videoDomain = Video.Video.decodeSync({ + ...video, + bucketId: video.bucket, + storageIntegrationId: video.storageIntegrationId, + createdAt: video.createdAt.toISOString(), + updatedAt: video.updatedAt.toISOString(), + metadata: video.metadata, + }); const fileKey = `${video.ownerId}/${video.id}/result.mp4`; const logContext = { videoId: video.id, ownerId: video.ownerId, - bucketId: video.bucketId ?? null, + bucketId: video.bucket ?? null, + storageIntegrationId: video.storageIntegrationId ?? null, fileKey, }; @@ -261,7 +286,7 @@ export async function deleteVideoResultFile({ try { await deleteResultObjectWithRetry({ - bucketIdOption, + video: videoDomain, fileKey, logContext, }); @@ -282,16 +307,17 @@ export async function deleteVideoResultFile({ } async function deleteResultObjectWithRetry({ - bucketIdOption, + video, fileKey, logContext, }: { - bucketIdOption: Option.Option; + video: Video.Video; fileKey: string; logContext: { videoId: Video.VideoId; ownerId: string; bucketId: string | null; + storageIntegrationId: string | null; fileKey: string; }; }) { @@ -301,7 +327,7 @@ async function deleteResultObjectWithRetry({ attempt += 1; try { await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(bucketIdOption); + const [bucket] = yield* StorageService.getAccessForVideo(video); yield* bucket.deleteObject(fileKey); }).pipe(runPromise); return; diff --git a/apps/web/actions/videos/download.ts b/apps/web/actions/videos/download.ts index 13acff3232..902bbd4db5 100644 --- a/apps/web/actions/videos/download.ts +++ b/apps/web/actions/videos/download.ts @@ -3,11 +3,12 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; +import { Storage } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Effect } from "effect"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; export async function downloadVideo(videoId: Video.VideoId) { const user = await getCurrentUser(); @@ -36,8 +37,8 @@ export async function downloadVideo(videoId: Video.VideoId) { const videoKey = `${video.ownerId}/${videoId}/result.mp4`; const downloadUrl = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(video.bucket), + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(video), ); return yield* bucket.getSignedObjectUrl(videoKey); }).pipe(runPromise); diff --git a/apps/web/actions/videos/edit-transcript.ts b/apps/web/actions/videos/edit-transcript.ts index c6dcb9465d..32ad3b6227 100644 --- a/apps/web/actions/videos/edit-transcript.ts +++ b/apps/web/actions/videos/edit-transcript.ts @@ -2,13 +2,14 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; +import { videos } from "@cap/database/schema"; +import { Storage } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Option } from "effect"; import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; export async function editTranscriptEntry( videoId: Video.VideoId, @@ -26,12 +27,8 @@ export async function editTranscriptEntry( const userId = user.id; const query = await db() - .select({ - video: videos, - bucket: s3Buckets, - }) + .select({ video: videos }) .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .where(eq(videos.id, videoId)); if (query.length === 0) { @@ -52,8 +49,8 @@ export async function editTranscriptEntry( }; } - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(result.bucket?.id), + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), ).pipe(runPromise); try { diff --git a/apps/web/actions/videos/get-available-translations.ts b/apps/web/actions/videos/get-available-translations.ts index db97840180..e4c6f13086 100644 --- a/apps/web/actions/videos/get-available-translations.ts +++ b/apps/web/actions/videos/get-available-translations.ts @@ -1,12 +1,13 @@ "use server"; import { db } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; +import { videos } from "@cap/database/schema"; +import { Storage } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Effect } from "effect"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; import { type LanguageCode, SUPPORTED_LANGUAGES, @@ -37,12 +38,8 @@ export async function getAvailableTranslations( } const query = await db() - .select({ - video: videos, - bucket: s3Buckets, - }) + .select({ video: videos }) .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .where(eq(videos.id, videoId)); if (query.length === 0 || !query[0]?.video) { @@ -59,8 +56,8 @@ export async function getAvailableTranslations( try { const result = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(query[0]?.bucket?.id), + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(video), ); const listResult = yield* bucket.listObjects({ diff --git a/apps/web/actions/videos/get-og-image.tsx b/apps/web/actions/videos/get-og-image.tsx index cc10ffac5a..468458faf8 100644 --- a/apps/web/actions/videos/get-og-image.tsx +++ b/apps/web/actions/videos/get-og-image.tsx @@ -1,11 +1,12 @@ import { db } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; +import { videos } from "@cap/database/schema"; +import { Storage } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Effect } from "effect"; import { ImageResponse } from "next/og"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; export async function generateVideoOgImage(videoId: Video.VideoId) { const videoData = await getData(videoId); @@ -65,8 +66,8 @@ export async function generateVideoOgImage(videoId: Video.VideoId) { try { await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(videoData.bucket?.id), + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(video), ); screenshotUrl = yield* bucket.getSignedObjectUrl(screenshotKey); @@ -127,20 +128,22 @@ export async function generateVideoOgImage(videoId: Video.VideoId) { strokeLinecap="round" strokeLinejoin="round" > + Play
{screenshotUrl && ( - )}
@@ -154,12 +157,8 @@ export async function generateVideoOgImage(videoId: Video.VideoId) { async function getData(videoId: Video.VideoId) { const query = await db() - .select({ - video: videos, - bucket: s3Buckets, - }) + .select({ video: videos }) .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .where(eq(videos.id, videoId)); const result = query[0]; @@ -168,6 +167,5 @@ async function getData(videoId: Video.VideoId) { return { video: result.video, - bucket: result.bucket, }; } diff --git a/apps/web/actions/videos/get-transcript.ts b/apps/web/actions/videos/get-transcript.ts index 90b910f7da..d957b0767c 100644 --- a/apps/web/actions/videos/get-transcript.ts +++ b/apps/web/actions/videos/get-transcript.ts @@ -2,12 +2,13 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; +import { videos } from "@cap/database/schema"; +import { Storage } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; export async function getTranscript( videoId: Video.VideoId, @@ -22,12 +23,8 @@ export async function getTranscript( } const query = await db() - .select({ - video: videos, - bucket: s3Buckets, - }) + .select({ video: videos }) .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .where(eq(videos.id, videoId)); if (query.length === 0) { @@ -50,8 +47,8 @@ export async function getTranscript( try { const vttContent = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(result.bucket?.id), + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(video), ); return yield* bucket.getObject( diff --git a/apps/web/actions/videos/translate-transcript.ts b/apps/web/actions/videos/translate-transcript.ts index c52b4e53d7..7ca24d4a10 100644 --- a/apps/web/actions/videos/translate-transcript.ts +++ b/apps/web/actions/videos/translate-transcript.ts @@ -1,13 +1,14 @@ "use server"; import { db } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; +import { videos } from "@cap/database/schema"; +import { Storage } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; import { type LanguageCode, SUPPORTED_LANGUAGES, @@ -46,12 +47,8 @@ export async function translateTranscript( } const query = await db() - .select({ - video: videos, - bucket: s3Buckets, - }) + .select({ video: videos }) .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .where(eq(videos.id, videoId)); if (query.length === 0 || !query[0]?.video) { @@ -64,8 +61,8 @@ export async function translateTranscript( try { const existingTranslation = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(query[0]?.bucket?.id), + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(video), ); return yield* bucket.getObject(translatedKey); }).pipe(runPromise); @@ -82,8 +79,8 @@ export async function translateTranscript( } const originalVtt = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(query[0]?.bucket?.id), + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(video), ); return yield* bucket.getObject( `${video.ownerId}/${videoId}/transcription.vtt`, @@ -106,8 +103,8 @@ export async function translateTranscript( try { await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(query[0]?.bucket?.id), + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(video), ); yield* bucket.putObject(translatedKey, translatedVtt, { contentType: "text/vtt", diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/instant-mp4-uploader.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/instant-mp4-uploader.ts index b9c931c6cb..28799cc721 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/instant-mp4-uploader.ts +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/instant-mp4-uploader.ts @@ -6,6 +6,7 @@ const MAX_PART_UPLOAD_ATTEMPTS = 3; const MAX_PARALLEL_PART_UPLOADS = 3; const MAX_PENDING_UPLOAD_BYTES = 128 * 1024 * 1024; const FINAL_BLOB_PART_SIZE_BYTES = 16 * 1024 * 1024; +const DRIVE_PART_SIZE_BYTES = 16 * 1024 * 1024; const PART_UPLOAD_STALL_TIMEOUT_MS = 30_000; const PART_UPLOAD_REQUEST_TIMEOUT_MS = 5 * 60 * 1000; @@ -95,20 +96,23 @@ export const initiateMultipartUpload = async ({ contentType: string; subpath: string; }) => { - const result = await postJson<{ uploadId: string }>( - "/api/upload/multipart/initiate", - { - videoId, - contentType: normalizeMultipartContentType(contentType), - subpath, - }, - ); + const result = await postJson<{ + uploadId: string; + provider?: "s3" | "googleDrive"; + }>("/api/upload/multipart/initiate", { + videoId, + contentType: normalizeMultipartContentType(contentType), + subpath, + }); if (!result.uploadId) { throw new Error("Multipart initiate response missing uploadId"); } - return result.uploadId; + return { + uploadId: result.uploadId, + provider: result.provider ?? "s3", + }; }; const presignMultipartPart = async ( @@ -116,17 +120,25 @@ const presignMultipartPart = async ( uploadId: string, partNumber: number, subpath: string, -): Promise => { - const result = await postJson<{ presignedUrl: string }>( - "/api/upload/multipart/presign-part", - { videoId, uploadId, partNumber, subpath }, - ); +): Promise<{ url: string; provider: "s3" | "googleDrive" }> => { + const result = await postJson<{ + presignedUrl: string; + provider?: "s3" | "googleDrive"; + }>("/api/upload/multipart/presign-part", { + videoId, + uploadId, + partNumber, + subpath, + }); if (!result.presignedUrl) { throw new Error(`Missing presigned URL for part ${partNumber}`); } - return result.presignedUrl; + return { + url: result.presignedUrl, + provider: result.provider ?? "s3", + }; }; const completeMultipartUpload = async ( @@ -181,6 +193,7 @@ interface FinalizeOptions extends MultipartCompletePayload { export class InstantRecordingUploader { private readonly videoId: VideoId; private readonly uploadId: string; + private readonly provider: "s3" | "googleDrive"; private readonly mimeType: string; private readonly subpath: string; private readonly setUploadStatus: SetUploadStatus; @@ -195,7 +208,7 @@ export class InstantRecordingUploader { private uploadedBytes = 0; private pendingUploadBytes = 0; private readonly pendingUploadTasks = new Set>(); - private availableUploadSlots = MAX_PARALLEL_PART_UPLOADS; + private availableUploadSlots: number; private readonly uploadSlotWaiters: Array<() => void> = []; private readonly parts: UploadedPartPayload[] = []; private nextPartNumber = 1; @@ -211,10 +224,13 @@ export class InstantRecordingUploader { >(); private readonly stallTimeouts = new Set(); private processingStarted = true; + private queuedBytes = 0; + private readonly partOffsets = new Map(); constructor(options: { videoId: VideoId; uploadId: string; + provider?: "s3" | "googleDrive"; mimeType: string; subpath: string; setUploadStatus: SetUploadStatus; @@ -225,6 +241,7 @@ export class InstantRecordingUploader { }) { this.videoId = options.videoId; this.uploadId = options.uploadId; + this.provider = options.provider ?? "s3"; this.mimeType = options.mimeType; this.subpath = options.subpath; this.setUploadStatus = options.setUploadStatus; @@ -232,6 +249,8 @@ export class InstantRecordingUploader { this.onChunkStateChange = options.onChunkStateChange; this.onOverflow = options.onOverflow; this.onFatalError = options.onFatalError; + this.availableUploadSlots = + this.provider === "googleDrive" ? 1 : MAX_PARALLEL_PART_UPLOADS; } private markFatalError(error: Error) { @@ -340,6 +359,11 @@ export class InstantRecordingUploader { } private flushBuffer(force = false) { + if (this.provider === "googleDrive") { + this.flushDriveBuffer(force); + return; + } + if (this.bufferedBytes === 0) return; if (!force && this.bufferedBytes < MIN_PART_SIZE_BYTES) return; @@ -350,10 +374,70 @@ export class InstantRecordingUploader { this.enqueueUpload(chunk); } + private flushDriveBuffer(force = false) { + while (this.bufferedBytes > 0) { + if (!force && this.bufferedBytes < DRIVE_PART_SIZE_BYTES) return; + + const partSize = + force && this.bufferedBytes <= DRIVE_PART_SIZE_BYTES + ? this.bufferedBytes + : DRIVE_PART_SIZE_BYTES; + const { part, remainingChunks, remainingBytes } = + this.takeBufferedPart(partSize); + + this.bufferedChunks = remainingChunks; + this.bufferedBytes = remainingBytes; + this.enqueueUpload(part); + + if (partSize < DRIVE_PART_SIZE_BYTES) return; + } + } + + private takeBufferedPart(size: number) { + const partChunks: Blob[] = []; + const remainingChunks: Blob[] = []; + let remainingPartBytes = size; + let consumedBuffer = true; + + for (const chunk of this.bufferedChunks) { + if (!consumedBuffer) { + remainingChunks.push(chunk); + continue; + } + + if (chunk.size <= remainingPartBytes) { + partChunks.push(chunk); + remainingPartBytes -= chunk.size; + if (remainingPartBytes === 0) consumedBuffer = false; + continue; + } + + partChunks.push(chunk.slice(0, remainingPartBytes, this.mimeType)); + remainingChunks.push( + chunk.slice(remainingPartBytes, chunk.size, this.mimeType), + ); + consumedBuffer = false; + remainingPartBytes = 0; + } + + return { + part: new Blob(partChunks, { type: this.mimeType }), + remainingChunks, + remainingBytes: this.bufferedBytes - size, + }; + } + private createFinalBlobPart(finalBlob: Blob, start: number, end: number) { return finalBlob.slice(start, end, this.mimeType); } + private resolveFinalTotalBytes(finalBlob?: Blob | null) { + return ( + finalBlob?.size ?? + Math.max(this.totalRecordedBytes, this.queuedBytes + this.bufferedBytes) + ); + } + private enqueueUpload(part: Blob) { if (this.pendingUploadBytes + part.size > MAX_PENDING_UPLOAD_BYTES) { const error = this.markFatalError( @@ -364,6 +448,8 @@ export class InstantRecordingUploader { } const partNumber = this.nextPartNumber++; + this.partOffsets.set(partNumber, this.queuedBytes); + this.queuedBytes += part.size; this.pendingUploadBytes += part.size; this.registerChunk(partNumber, part.size); const uploadTask = this.runPartUpload(partNumber, part); @@ -432,7 +518,7 @@ export class InstantRecordingUploader { } this.availableUploadSlots = Math.min( - MAX_PARALLEL_PART_UPLOADS, + this.provider === "googleDrive" ? 1 : MAX_PARALLEL_PART_UPLOADS, this.availableUploadSlots + 1, ); } @@ -541,7 +627,7 @@ export class InstantRecordingUploader { } private async uploadPart(partNumber: number, part: Blob) { - const presignedUrl = await presignMultipartPart( + const upload = await presignMultipartPart( this.videoId, this.uploadId, partNumber, @@ -549,7 +635,8 @@ export class InstantRecordingUploader { ); const etag = await this.uploadBlobWithProgress({ - url: presignedUrl, + url: upload.url, + provider: upload.provider, partNumber, part, }); @@ -562,10 +649,12 @@ export class InstantRecordingUploader { private uploadBlobWithProgress({ url, + provider, partNumber, part, }: { url: string; + provider: "s3" | "googleDrive"; partNumber: number; part: Blob; }): Promise { @@ -579,6 +668,19 @@ export class InstantRecordingUploader { xhr.open("PUT", url); xhr.responseType = "text"; xhr.timeout = PART_UPLOAD_REQUEST_TIMEOUT_MS; + let isFinalDrivePart = false; + if (provider === "googleDrive") { + const start = this.partOffsets.get(partNumber) ?? 0; + const end = start + part.size - 1; + isFinalDrivePart = + this.finalTotalBytes !== null && end + 1 >= this.finalTotalBytes; + const total = + isFinalDrivePart && this.finalTotalBytes !== null + ? this.finalTotalBytes.toString() + : "*"; + xhr.setRequestHeader("Content-Range", `bytes ${start}-${end}/${total}`); + xhr.setRequestHeader("Content-Type", this.mimeType); + } this.activeRequests.set(partNumber, xhr); this.updateChunkState(partNumber, { @@ -628,9 +730,16 @@ export class InstantRecordingUploader { xhr.onload = () => { clearStallTimeout(); clearRequest(); - if (xhr.status >= 200 && xhr.status < 300) { + if ( + (xhr.status >= 200 && xhr.status < 300) || + (provider === "googleDrive" && + xhr.status === 308 && + !isFinalDrivePart) + ) { const etagHeader = xhr.getResponseHeader("ETag"); - const etag = etagHeader?.replace(/"/g, ""); + const etag = + etagHeader?.replace(/"/g, "") || + (provider === "googleDrive" ? `drive-${partNumber}` : ""); if (!etag) { this.updateChunkState(partNumber, { status: "error" }); reject(new Error(`Missing ETag for part ${partNumber}`)); @@ -687,7 +796,17 @@ export class InstantRecordingUploader { throw this.fatalError; } - if (options.finalBlob) { + const finalTotalBytes = this.resolveFinalTotalBytes(options.finalBlob); + + if (this.provider === "googleDrive") { + if (finalTotalBytes <= 0) { + throw new Error( + "Cannot finalize Google Drive upload without a byte count", + ); + } + this.finalTotalBytes = finalTotalBytes; + this.totalRecordedBytes = finalTotalBytes; + } else if (options.finalBlob) { this.finalTotalBytes = options.finalBlob.size; this.totalRecordedBytes = options.finalBlob.size; } diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-upload.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-upload.ts index f449cccea4..e7cafe194a 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-upload.ts +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-upload.ts @@ -1,10 +1,11 @@ +import { uploadWithTarget } from "@/utils/upload-target"; import type { UploadStatus } from "../../UploadingContext"; import { sendProgressUpdate } from "../sendProgressUpdate"; -import type { PresignedPost, VideoId } from "./web-recorder-types"; +import type { UploadTarget, VideoId } from "./web-recorder-types"; export const uploadRecording = ( blob: Blob, - upload: PresignedPost, + upload: UploadTarget, currentVideoId: VideoId, thumbnailPreviewUrl: string | undefined, setUploadStatus: (status: UploadStatus | undefined) => void, @@ -20,48 +21,25 @@ export const uploadRecording = ( ? blob : new File([blob], "result.mp4", { type: "video/mp4" }); - const formData = new FormData(); - Object.entries(upload.fields).forEach(([key, value]) => { - formData.append(key, value); - }); - formData.append("file", fileBlob, "result.mp4"); - - const xhr = new XMLHttpRequest(); - xhr.open("POST", upload.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; + uploadWithTarget({ + target: upload, + body: fileBlob, + fileName: "result.mp4", + onProgress: ({ loaded, total }) => { + const percent = (loaded / total) * 100; setUploadStatus({ status: "uploadingVideo", capId: currentVideoId, progress: percent, thumbnailUrl: thumbnailPreviewUrl, }); - sendProgressUpdate(currentVideoId, event.loaded, event.total); - } - }; - - xhr.onload = async () => { - if (xhr.status >= 200 && xhr.status < 300) { + void sendProgressUpdate(currentVideoId, loaded, total); + }, + }).then( + async () => { await sendProgressUpdate(currentVideoId, blob.size, blob.size); resolve(); - } else { - const errorText = xhr.responseText || xhr.statusText || "Unknown error"; - console.error("Upload failed:", { - status: xhr.status, - statusText: xhr.statusText, - responseText: errorText, - }); - reject( - new Error(`Upload failed with status ${xhr.status}: ${errorText}`), - ); - } - }; - - xhr.onerror = () => { - reject(new Error("Upload failed due to network error")); - }; - - xhr.send(formData); + }, + (error) => reject(error), + ); }); diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts index 01d05d7f73..2016cc8d01 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts @@ -9,6 +9,7 @@ import { toast } from "sonner"; import { createVideoAndGetUploadUrl } from "@/actions/video/upload"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest"; +import { uploadWithTarget } from "@/utils/upload-target"; import { useUploadingContext } from "../../UploadingContext"; import { sendProgressUpdate } from "../sendProgressUpdate"; import { @@ -44,10 +45,10 @@ import { } from "./web-recorder-constants"; import type { ChunkUploadState, - PresignedPost, RecorderPhase, RecordingFailureDownload, RecoveredRecordingDownload, + UploadTarget, VideoId, } from "./web-recorder-types"; import { @@ -81,7 +82,7 @@ type InstantChunkingMode = "manual" | "timeslice"; type InstantVideoCreation = { id: VideoId; shareUrl: string; - upload: PresignedPost; + upload: UploadTarget; }; const unwrapExitOrThrow = (exit: Exit.Exit) => { @@ -236,7 +237,7 @@ export const useWebRecorder = ({ const videoCreationRef = useRef<{ id: VideoId; shareUrl: string; - upload: PresignedPost; + upload: UploadTarget; } | null>(null); const pendingInstantVideoIdRef = useRef(null); const dataRequestIntervalRef = useRef(null); @@ -999,14 +1000,15 @@ export const useWebRecorder = ({ pendingInstantVideoIdRef.current = creation.id; const rawSubpath = `raw-upload.${pipeline.fileExtension}`; - const uploadId = await initiateMultipartUpload({ + const uploadSession = await initiateMultipartUpload({ videoId: creationResult.id, contentType: pipeline.mimeType, subpath: rawSubpath, }); instantUploaderRef.current = new InstantRecordingUploader({ videoId: creationResult.id, - uploadId, + uploadId: uploadSession.uploadId, + provider: uploadSession.provider, mimeType: pipeline.mimeType, subpath: rawSubpath, setUploadStatus, @@ -1220,14 +1222,15 @@ export const useWebRecorder = ({ const rawSubpath = `raw-upload.${pipeline.fileExtension}`; if (!uploader) { - const uploadId = await initiateMultipartUpload({ + const uploadSession = await initiateMultipartUpload({ videoId: creationResult.id, contentType: pipeline.mimeType, subpath: rawSubpath, }); uploader = new InstantRecordingUploader({ videoId: creationResult.id, - uploadId, + uploadId: uploadSession.uploadId, + provider: uploadSession.provider, mimeType: pipeline.mimeType, subpath: rawSubpath, setUploadStatus, @@ -1303,56 +1306,24 @@ export const useWebRecorder = ({ orgId: Organisation.OrganisationId.make(orgId), }); - const screenshotFormData = new FormData(); - Object.entries(screenshotData.presignedPostData.fields).forEach( - ([key, value]) => { - screenshotFormData.append(key, value as string); - }, - ); - screenshotFormData.append( - "file", - thumbnailBlob, - "screen-capture.jpg", - ); - setUploadStatus({ status: "uploadingThumbnail", capId: creationResult.id, progress: 90, }); - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", screenshotData.presignedPostData.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = 90 + (event.loaded / event.total) * 10; - setUploadStatus({ - status: "uploadingThumbnail", - capId: creationResult.id, - progress: percent, - }); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject( - new Error( - `Screenshot upload failed with status ${xhr.status}`, - ), - ); - } - }; - - xhr.onerror = () => { - reject(new Error("Screenshot upload failed")); - }; - - xhr.send(screenshotFormData); + await uploadWithTarget({ + target: screenshotData.uploadTarget, + body: thumbnailBlob, + fileName: "screen-capture.jpg", + onProgress: ({ loaded, total }) => { + const percent = 90 + (loaded / total) * 10; + setUploadStatus({ + status: "uploadingThumbnail", + capId: creationResult.id, + progress: percent, + }); + }, }); queryClient.refetchQueries({ diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-types.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-types.ts index 078bc5a232..6a1368c828 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-types.ts +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-types.ts @@ -11,14 +11,16 @@ export type RecorderPhase = export type RecorderErrorEvent = Event & { error?: DOMException }; type VideoNamespace = typeof import("@cap/web-domain").Video; +type StorageNamespace = typeof import("@cap/web-domain").Storage; export type PresignedPost = VideoNamespace["PresignedPost"]["Type"]; +export type UploadTarget = StorageNamespace["UploadTarget"]["Type"]; export type VideoId = VideoNamespace["VideoId"]["Type"]; export type ChunkUploadState = { partNumber: number; sizeBytes: number; uploadedBytes: number; - progress: number; // 0-1 ratio for the chunk itself + progress: number; status: "queued" | "uploading" | "complete" | "error"; }; diff --git a/apps/web/app/(org)/dashboard/import/file/ImportFilePage.tsx b/apps/web/app/(org)/dashboard/import/file/ImportFilePage.tsx index 2721f655cc..7a39ef1282 100644 --- a/apps/web/app/(org)/dashboard/import/file/ImportFilePage.tsx +++ b/apps/web/app/(org)/dashboard/import/file/ImportFilePage.tsx @@ -18,6 +18,7 @@ import { useUploadingContext, } from "@/app/(org)/dashboard/caps/UploadingContext"; import { UpgradeModal } from "@/components/UpgradeModal"; +import { uploadWithTarget } from "@/utils/upload-target"; export const ImportFilePage = () => { const { user, activeOrganization } = useDashboardContext(); @@ -243,14 +244,6 @@ async function uploadVideoForServerProcessing( thumbnailUrl: undefined, }); - const formData = new FormData(); - Object.entries(videoData.presignedPostData.fields).forEach( - ([key, value]) => { - formData.append(key, value as string); - }, - ); - formData.append("file", file); - const createProgressTracker = () => { const uploadState = { videoId: uploadId, @@ -301,42 +294,25 @@ async function uploadVideoForServerProcessing( const progressTracker = createProgressTracker(); try { - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", videoData.presignedPostData.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - setUploadStatus({ - status: "uploadingVideo", - capId: uploadId, - progress: percent, - thumbnailUrl: undefined, - }); - - progressTracker.scheduleProgressUpdate(event.loaded, event.total); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - progressTracker.cleanup(); - const total = progressTracker.getTotal() || 1; - sendProgressUpdate(uploadId, total, total); - resolve(); - } else { - progressTracker.cleanup(); - reject(new Error(`Upload failed with status ${xhr.status}`)); - } - }; - xhr.onerror = () => { - progressTracker.cleanup(); - reject(new Error("Upload failed")); - }; - - xhr.send(formData); + await uploadWithTarget({ + target: videoData.uploadTarget, + body: file, + fileName: file.name, + onProgress: ({ loaded, total }) => { + const percent = (loaded / total) * 100; + setUploadStatus({ + status: "uploadingVideo", + capId: uploadId, + progress: percent, + thumbnailUrl: undefined, + }); + + progressTracker.scheduleProgressUpdate(loaded, total); + }, }); + progressTracker.cleanup(); + const total = progressTracker.getTotal() || 1; + sendProgressUpdate(uploadId, total, total); } catch (uploadError) { progressTracker.cleanup(); throw uploadError; diff --git a/apps/web/app/Layout/providers.tsx b/apps/web/app/Layout/providers.tsx index de6d03b41c..79ff4a0675 100644 --- a/apps/web/app/Layout/providers.tsx +++ b/apps/web/app/Layout/providers.tsx @@ -1,7 +1,10 @@ "use client"; import { buildEnv } from "@cap/env"; -import { TanStackDevtools } from "@tanstack/react-devtools"; +import { + TanStackDevtools, + type TanStackDevtoolsReactInit, +} from "@tanstack/react-devtools"; import { QueryClient, QueryClientProvider, @@ -21,6 +24,10 @@ import type { BootstrapData } from "@/utils/getBootstrapData"; import PostHogPageView from "./PosthogPageView"; +type CapPostHogConfig = Partial & { + disable_session_recording?: boolean; +}; + export function PostHogProvider({ children, bootstrapData, @@ -35,17 +42,17 @@ export function PostHogProvider({ const options = useMemo(() => { if (!host) return undefined; - const base = { + const base: CapPostHogConfig = { api_host: host, capture_pageview: false, capture_pageleave: true, bootstrap: initialBootstrap.current?.distinctID ? initialBootstrap.current : undefined, - } satisfies Partial; + }; if (process.env.NEXT_PUBLIC_POSTHOG_DISABLE_SESSION_RECORDING === "true") { - (base as any).disable_session_recording = true; + base.disable_session_recording = true; } return base; @@ -138,27 +145,78 @@ import { promoteToPro, restartOnboarding, } from "./devtoolsServer"; -import { useFeatureFlags } from "./features"; export function SessionProvider({ children }: PropsWithChildren) { return {children}; } +type DevtoolsConfig = NonNullable; + +const devtoolsSettingsStorageKey = "tanstack_devtools_settings"; + +const devtoolsConfig = { + hideUntilHover: false, + position: "top-left", + requireUrlFlag: false, +} satisfies DevtoolsConfig; + +function getDevtoolsSettings(value: string | null): DevtoolsConfig { + if (!value) return devtoolsConfig; + + try { + const parsed: unknown = JSON.parse(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return { + ...parsed, + ...devtoolsConfig, + }; + } + } catch { + return devtoolsConfig; + } + + return devtoolsConfig; +} + +function persistDevtoolsSettings() { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem( + devtoolsSettingsStorageKey, + JSON.stringify( + getDevtoolsSettings( + window.localStorage.getItem(devtoolsSettingsStorageKey), + ), + ), + ); + } catch { + return; + } +} + export function Devtools() { const client = useQueryClient(); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + persistDevtoolsSettings(); + setIsReady(true); + }, []); + + if (!isReady) return null; return ( , }, { + id: "tanstack-query", name: "Tanstack Query", render: , }, @@ -168,28 +226,9 @@ export function Devtools() { } function CapDevtools() { - const flags = useFeatureFlags(); - void flags; - return (

Cap Devtools

- {/*
-

Features

- -
*/}

Cap Pro

diff --git a/apps/web/app/api/desktop/[...route]/route.ts b/apps/web/app/api/desktop/[...route]/route.ts index 9255bc7ebc..3685720004 100644 --- a/apps/web/app/api/desktop/[...route]/route.ts +++ b/apps/web/app/api/desktop/[...route]/route.ts @@ -6,6 +6,7 @@ import { corsMiddleware } from "../../utils"; import * as root from "./root"; import * as s3Config from "./s3Config"; import * as session from "./session"; +import * as storage from "./storage"; import * as video from "./video"; const app = new Hono() @@ -13,6 +14,7 @@ const app = new Hono() .use(corsMiddleware) .route("/s3/config", s3Config.app) .route("/session", session.app) + .route("/storage", storage.app) .route("/video", video.app) .route("/", root.app); diff --git a/apps/web/app/api/desktop/[...route]/storage.ts b/apps/web/app/api/desktop/[...route]/storage.ts new file mode 100644 index 0000000000..5f0f9d739f --- /dev/null +++ b/apps/web/app/api/desktop/[...route]/storage.ts @@ -0,0 +1,411 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { db } from "@cap/database"; +import { decrypt, encrypt } from "@cap/database/crypto"; +import { nanoId } from "@cap/database/helpers"; +import { + storageIntegrations, + storageObjects, + videos, +} from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import { userIsPro } from "@cap/utils"; +import { + ensureGoogleDriveFolder, + exchangeGoogleDriveCode, + type GoogleDriveIntegrationConfig, + getGoogleDriveAuthUrl, + getGoogleDriveUserEmail, +} from "@cap/web-backend"; +import { Storage, User } from "@cap/web-domain"; +import { zValidator } from "@hono/zod-validator"; +import { and, desc, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import { getCachedGoogleDriveStorageQuota } from "@/lib/google-drive-storage-quota"; +import { runPromise } from "@/lib/server"; +import { withAuth } from "../../utils"; + +const GoogleDriveOAuthState = z.object({ + userId: z.string(), + expiresAt: z.number(), +}); + +const googleDriveProvider = "googleDrive"; + +const RefreshStorageQuotaQuery = z.object({ + refreshStorageQuota: z + .union([z.literal("true"), z.literal("false"), z.boolean()]) + .optional() + .transform((value) => value === true || value === "true"), +}); + +const signStatePayload = (payload: string) => + createHmac("sha256", serverEnv().NEXTAUTH_SECRET) + .update(payload) + .digest("base64url"); + +const createGoogleDriveState = (userId: string) => { + const payload = Buffer.from( + JSON.stringify({ + userId, + expiresAt: Date.now() + 10 * 60 * 1000, + }), + ).toString("base64url"); + return `${payload}.${signStatePayload(payload)}`; +}; + +const verifyGoogleDriveState = (state: string) => { + const [payload, signature] = state.split("."); + if (!payload || !signature) throw new Error("Invalid OAuth state"); + const expected = signStatePayload(payload); + const signatureBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expected); + if ( + signatureBuffer.length !== expectedBuffer.length || + !timingSafeEqual(signatureBuffer, expectedBuffer) + ) { + throw new Error("Invalid OAuth state"); + } + + const parsed = GoogleDriveOAuthState.parse( + JSON.parse(Buffer.from(payload, "base64url").toString("utf8")), + ); + if (parsed.expiresAt < Date.now()) throw new Error("Expired OAuth state"); + return User.UserId.make(parsed.userId); +}; + +const escapeHtml = (value: string) => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +const htmlResponse = (title: string, body: string) => ` + + + + +${escapeHtml(title)} + + + +

+

${escapeHtml(title)}

+

${escapeHtml(body)}

+
+ +`; + +const getGoogleDriveIntegration = (ownerId: User.UserId) => + db() + .select() + .from(storageIntegrations) + .where( + and( + eq(storageIntegrations.ownerId, ownerId), + eq(storageIntegrations.provider, googleDriveProvider), + ), + ) + .orderBy( + desc(storageIntegrations.active), + desc(storageIntegrations.updatedAt), + ) + .limit(1); + +export const app = new Hono(); + +const protectedApp = new Hono().use(withAuth); + +protectedApp.get( + "/integrations", + zValidator("query", RefreshStorageQuotaQuery), + async (c) => { + const user = c.get("user"); + const { refreshStorageQuota } = c.req.valid("query"); + const [drive] = await getGoogleDriveIntegration(user.id); + const storageQuota = drive + ? await getCachedGoogleDriveStorageQuota(drive, { + forceRefresh: refreshStorageQuota, + }) + : null; + + return c.json({ + activeProvider: + drive?.active && drive.status === "active" ? "googleDrive" : "s3", + googleDrive: drive + ? { + id: drive.id, + connected: drive.status === "active", + active: drive.active, + status: drive.status, + displayName: drive.displayName, + storageQuota, + } + : { + id: null, + connected: false, + active: false, + status: null, + displayName: null, + storageQuota: null, + }, + }); + }, +); + +protectedApp.post("/google-drive/connect", async (c) => { + const user = c.get("user"); + if (!userIsPro(user)) { + return c.json({ error: "upgrade_required" }, { status: 403 }); + } + + const state = createGoogleDriveState(user.id); + return c.json({ + url: getGoogleDriveAuthUrl({ state }), + }); +}); + +protectedApp.post("/google-drive/test", async (c) => { + const user = c.get("user"); + const [drive] = await getGoogleDriveIntegration(user.id); + if (!drive || drive.status !== "active") { + return c.json({ error: "not_connected" }, { status: 404 }); + } + + const config = JSON.parse( + await decrypt(drive.encryptedConfig), + ) as GoogleDriveIntegrationConfig; + const email = await getGoogleDriveUserEmail(config).pipe(runPromise); + + return c.json({ success: true, email: email ?? null }); +}); + +protectedApp.post( + "/set-active", + zValidator( + "json", + z.object({ + provider: z.enum(["s3", "googleDrive"]), + }), + ), + async (c) => { + const user = c.get("user"); + const { provider } = c.req.valid("json"); + + const activated = await db().transaction(async (tx) => { + const [driveToActivate] = + provider === "googleDrive" + ? await tx + .select() + .from(storageIntegrations) + .where( + and( + eq(storageIntegrations.ownerId, user.id), + eq(storageIntegrations.provider, googleDriveProvider), + eq(storageIntegrations.status, "active"), + ), + ) + .orderBy( + desc(storageIntegrations.active), + desc(storageIntegrations.updatedAt), + ) + .limit(1) + : []; + + if (provider === "googleDrive" && !driveToActivate) { + return false; + } + + await tx + .update(storageIntegrations) + .set({ active: false }) + .where(eq(storageIntegrations.ownerId, user.id)); + + if (provider === "googleDrive" && driveToActivate) { + await tx + .update(storageIntegrations) + .set({ active: true }) + .where(eq(storageIntegrations.id, driveToActivate.id)); + } + + return true; + }); + + if (!activated) { + return c.json({ error: "not_connected" }, { status: 404 }); + } + + return c.json({ success: true }); + }, +); + +protectedApp.delete("/google-drive/disconnect", async (c) => { + const user = c.get("user"); + await db() + .update(storageIntegrations) + .set({ + active: false, + status: "disconnected", + googleDriveAccessToken: null, + googleDriveAccessTokenExpiresAt: null, + googleDriveTokenRefreshLeaseId: null, + googleDriveTokenRefreshLeaseExpiresAt: null, + googleDriveStorageQuotaCache: null, + }) + .where( + and( + eq(storageIntegrations.ownerId, user.id), + eq(storageIntegrations.provider, googleDriveProvider), + ), + ); + + return c.json({ success: true }); +}); + +app.route("/", protectedApp); + +app.get("/google-drive/callback", async (c) => { + try { + const error = c.req.query("error"); + if (error) { + return c.html( + htmlResponse( + "Google Drive was not connected", + "You can close this window and try again from Cap settings.", + ), + 400, + ); + } + + const code = c.req.query("code"); + const state = c.req.query("state"); + if (!code || !state) { + return c.html( + htmlResponse( + "Google Drive was not connected", + "The authorization response was missing required data.", + ), + 400, + ); + } + + const userId = verifyGoogleDriveState(state); + const tokens = await exchangeGoogleDriveCode(code).pipe(runPromise); + if (!tokens.refresh_token) throw new Error("Missing refresh token"); + + const initialConfig: GoogleDriveIntegrationConfig = { + refreshToken: tokens.refresh_token, + folderId: "", + scope: tokens.scope, + }; + const folderId = await ensureGoogleDriveFolder(initialConfig, "Cap").pipe( + runPromise, + ); + const config: GoogleDriveIntegrationConfig = { + ...initialConfig, + folderId, + }; + const email = await getGoogleDriveUserEmail(config).pipe(runPromise); + const encryptedConfig = await encrypt( + JSON.stringify({ ...config, email: email ?? undefined }), + ); + const displayName = email ? `Google Drive (${email})` : "Google Drive"; + + await db().transaction(async (tx) => { + const existingIntegrations = await tx + .select() + .from(storageIntegrations) + .where( + and( + eq(storageIntegrations.ownerId, userId), + eq(storageIntegrations.provider, googleDriveProvider), + ), + ) + .orderBy(desc(storageIntegrations.updatedAt)); + + const dependencyChecks = await Promise.all( + existingIntegrations.map(async (integration) => { + const [object] = await tx + .select({ id: storageObjects.id }) + .from(storageObjects) + .where(eq(storageObjects.integrationId, integration.id)) + .limit(1); + const [video] = await tx + .select({ id: videos.id }) + .from(videos) + .where(eq(videos.storageIntegrationId, integration.id)) + .limit(1); + + return { + integration, + hasStoredData: Boolean(object || video), + }; + }), + ); + const reusable = dependencyChecks.find( + ({ hasStoredData }) => !hasStoredData, + )?.integration; + + await tx + .update(storageIntegrations) + .set({ active: false, status: "disconnected" }) + .where( + and( + eq(storageIntegrations.ownerId, userId), + eq(storageIntegrations.provider, googleDriveProvider), + ), + ); + + if (reusable) { + await tx + .update(storageIntegrations) + .set({ + displayName, + status: "active", + active: true, + encryptedConfig, + googleDriveAccessToken: null, + googleDriveAccessTokenExpiresAt: null, + googleDriveTokenRefreshLeaseId: null, + googleDriveTokenRefreshLeaseExpiresAt: null, + googleDriveStorageQuotaCache: null, + }) + .where(eq(storageIntegrations.id, reusable.id)); + return; + } + + await tx.insert(storageIntegrations).values({ + id: Storage.StorageIntegrationId.make(nanoId()), + ownerId: userId, + provider: googleDriveProvider, + displayName, + status: "active", + active: true, + encryptedConfig, + }); + }); + + return c.html( + htmlResponse( + "Google Drive connected", + "Return to Cap settings to manage your storage provider.", + ), + ); + } catch (error) { + console.error("Google Drive OAuth callback failed:", error); + return c.html( + htmlResponse( + "Google Drive was not connected", + "You can close this window and try again from Cap settings.", + ), + 500, + ); + } +}); diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 5a159aa096..72b6d9b2e5 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -6,22 +6,28 @@ import { importedVideos, organizationMembers, organizations, - s3Buckets, users, videos, videoUploads, } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub, userIsPro } from "@cap/utils"; -import { S3Buckets } from "@cap/web-backend"; +import { Storage } from "@cap/web-backend"; import { Organisation, Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, count, eq, lte, or } from "drizzle-orm"; import { Effect, Option } from "effect"; import { Hono } from "hono"; import { z } from "zod"; +import { invalidateGoogleDriveStorageQuotaCache } from "@/lib/google-drive-storage-quota"; import { runPromise } from "@/lib/server"; -import { isFromDesktopSemver, UPLOAD_PROGRESS_VERSION } from "@/utils/desktop"; +import { decodeStorageVideo } from "@/lib/video-storage"; +import { + GOOGLE_DRIVE_UPLOAD_FEATURE, + hasDesktopFeature, + isFromDesktopSemver, + UPLOAD_PROGRESS_VERSION, +} from "@/utils/desktop"; import { stringOrNumberOptional } from "@/utils/zod"; import { withAuth } from "../../utils"; @@ -85,11 +91,6 @@ app.get( fps, }); - const [customBucket] = await db() - .select() - .from(s3Buckets) - .where(eq(s3Buckets.ownerId, user.id)); - const date = new Date(); const formattedDate = `${date.getDate()} ${date.toLocaleString( "default", @@ -169,6 +170,14 @@ app.get( const videoName = name ?? `Cap ${isScreenshot ? "Screenshot" : "Recording"} - ${formattedDate}`; + const clientSupportsGoogleDriveUpload = hasDesktopFeature( + c.req, + GOOGLE_DRIVE_UPLOAD_FEATURE, + ); + const writable = await (clientSupportsGoogleDriveUpload + ? Storage.getWritableAccessForUser(user.id) + : Storage.getS3WritableAccessForUser(user.id) + ).pipe(runPromise); await db() .insert(videos) @@ -186,7 +195,8 @@ app.get( ? { type: "desktopSegments" as const } : undefined, isScreenshot, - bucket: customBucket?.id, + bucket: Option.getOrNull(writable.bucketId), + storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, duration: durationInSecs, width, @@ -273,9 +283,8 @@ app.delete( try { const [result] = await db() - .select({ video: videos, bucket: s3Buckets }) + .select({ video: videos }) .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); if (!result) @@ -292,9 +301,8 @@ app.delete( .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(result.bucket?.id), - ); + const video = decodeStorageVideo(result.video); + const [bucket] = yield* Storage.getAccessForVideo(video); const listedObjects = yield* bucket.listObjects({ prefix: `${user.id}/${videoId}/`, @@ -302,11 +310,14 @@ app.delete( if (listedObjects.Contents) yield* bucket.deleteObjects( - listedObjects.Contents.map((content: any) => ({ + listedObjects.Contents.map((content) => ({ Key: content.Key, })), ); }).pipe(runPromise); + await invalidateGoogleDriveStorageQuotaCache( + result.video.storageIntegrationId, + ); return c.json(true); } catch (error) { @@ -344,7 +355,11 @@ app.post( try { const [video] = await db() - .select({ id: videos.id, upload: videoUploads }) + .select({ + id: videos.id, + storageIntegrationId: videos.storageIntegrationId, + upload: videoUploads, + }) .from(videos) .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))) .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)); @@ -382,6 +397,11 @@ app.post( updatedAt, }); } + if (uploaded === total) { + await invalidateGoogleDriveStorageQuotaCache( + video.storageIntegrationId, + ); + } return c.json(true); } catch (error) { diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts index 19cd1161f2..70f271532f 100644 --- a/apps/web/app/api/playlist/route.ts +++ b/apps/web/app/api/playlist/route.ts @@ -3,7 +3,7 @@ import { serverEnv } from "@cap/env"; import { Database, provideOptionalAuth, - S3Buckets, + Storage, Videos, } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -58,7 +58,7 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( Layer.provide( HttpApiBuilder.group(Api, "root", (handlers) => Effect.gen(function* () { - const s3Buckets = yield* S3Buckets; + const storage = yield* Storage; const videos = yield* Videos; return handlers.handle("getVideoSrc", ({ urlParams }) => @@ -81,10 +81,10 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( VerifyVideoPasswordError: () => new HttpApiError.Forbidden(), PolicyDenied: () => new HttpApiError.Unauthorized(), DatabaseError: () => new HttpApiError.InternalServerError(), - S3Error: () => new HttpApiError.InternalServerError(), + StorageError: () => new HttpApiError.InternalServerError(), UnknownException: () => new HttpApiError.InternalServerError(), }), - Effect.provideService(S3Buckets, s3Buckets), + Effect.provideService(Storage, storage), ), ); }), @@ -95,7 +95,7 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( const resolveRawPreviewKey = (video: Video.Video) => Effect.gen(function* () { const db = yield* Database; - const [s3] = yield* S3Buckets.getBucketAccess(video.bucketId); + const [bucket] = yield* Storage.getAccessForVideo(video); const [uploadRecord] = yield* db.use((db) => db .select({ rawFileKey: Db.videoUploads.rawFileKey }) @@ -116,7 +116,7 @@ const resolveRawPreviewKey = (video: Video.Video) => `${video.ownerId}/${video.id}/raw-upload.webm`, ]; const headResults = yield* Effect.all( - candidateKeys.map((key) => s3.headObject(key).pipe(Effect.option)), + candidateKeys.map((key) => bucket.headObject(key).pipe(Effect.option)), { concurrency: "unbounded" }, ); for (const [index, candidateKey] of candidateKeys.entries()) { @@ -138,13 +138,13 @@ const getPlaylistResponse = ( urlParams: (typeof GetPlaylistParams)["Type"], ) => Effect.gen(function* () { - const [s3, customBucket] = yield* S3Buckets.getBucketAccess(video.bucketId); + const [bucket, customBucket] = yield* Storage.getAccessForVideo(video); const isMp4Source = video.source.type === "desktopMP4" || video.source.type === "webMP4"; if (urlParams.videoType === "raw-preview") { const rawFileKey = yield* resolveRawPreviewKey(video); - return yield* s3 + return yield* bucket .getSignedObjectUrl(rawFileKey) .pipe(Effect.map(HttpServerResponse.redirect)); } @@ -160,7 +160,7 @@ const getPlaylistResponse = ( }); const manifestKey = segSource.getManifestKey(); - const manifestContent = yield* s3.getObject(manifestKey).pipe( + const manifestContent = yield* bucket.getObject(manifestKey).pipe( Effect.andThen( Option.match({ onNone: () => Effect.fail(new HttpApiError.NotFound()), @@ -226,13 +226,13 @@ const getPlaylistResponse = ( return yield* Effect.fail(new HttpApiError.NotFound()); } - const initUrl = yield* s3.getSignedObjectUrl(initKey); + const initUrl = yield* bucket.getSignedObjectUrl(initKey); const segmentUrls = yield* Effect.all( segments.map((seg) => { const key = isVideo ? segSource.getVideoSegmentKey(seg.index) : segSource.getAudioSegmentKey(seg.index); - return s3.getSignedObjectUrl(key); + return bucket.getSignedObjectUrl(key); }), { concurrency: "unbounded" }, ); @@ -265,7 +265,7 @@ const getPlaylistResponse = ( }); } - if (Option.isNone(customBucket)) { + if (bucket.provider === "s3" && Option.isNone(customBucket)) { let redirect = `${video.ownerId}/${video.id}/combined-source/stream.m3u8`; if (isMp4Source || urlParams.videoType === "mp4") @@ -274,7 +274,7 @@ const getPlaylistResponse = ( redirect = `${video.ownerId}/${video.id}/output/video_recording_000.m3u8`; return HttpServerResponse.redirect( - yield* s3.getSignedObjectUrl(redirect), + yield* bucket.getSignedObjectUrl(redirect), ); } @@ -282,7 +282,7 @@ const getPlaylistResponse = ( Option.isSome(urlParams.fileType) && urlParams.fileType.value === "transcription" ) { - return yield* s3 + return yield* bucket .getObject(`${video.ownerId}/${video.id}/transcription.vtt`) .pipe( Effect.andThen( @@ -306,9 +306,9 @@ const getPlaylistResponse = ( urlParams.fileType.value === "enhanced-audio" ) { const enhancedAudioKey = `${video.ownerId}/${video.id}/enhanced-audio.mp3`; - return yield* s3.getSignedObjectUrl(enhancedAudioKey).pipe( + return yield* bucket.getSignedObjectUrl(enhancedAudioKey).pipe( Effect.map(HttpServerResponse.redirect), - Effect.catchTag("S3Error", () => new HttpApiError.NotFound()), + Effect.catchTag("StorageError", () => new HttpApiError.NotFound()), Effect.withSpan("fetchEnhancedAudio"), ); } @@ -321,7 +321,7 @@ const getPlaylistResponse = ( return yield* Effect.gen(function* () { if (video.source.type === "local") { const playlistText = - (yield* s3.getObject( + (yield* bucket.getObject( `${video.ownerId}/${video.id}/combined-source/stream.m3u8`, )).pipe(Option.getOrNull) ?? ""; @@ -329,7 +329,7 @@ const getPlaylistResponse = ( for (const [index, line] of lines.entries()) { if (line.endsWith(".ts")) { - const url = yield* s3.getSignedObjectUrl( + const url = yield* bucket.getSignedObjectUrl( `${video.ownerId}/${video.id}/combined-source/${line}`, ); lines[index] = url; @@ -348,15 +348,15 @@ const getPlaylistResponse = ( yield* Effect.log( `Returning path ${`${video.ownerId}/${video.id}/result.mp4`}`, ); - return yield* s3 + return yield* bucket .getSignedObjectUrl(`${video.ownerId}/${video.id}/result.mp4`) .pipe(Effect.map(HttpServerResponse.redirect)); } if (urlParams.videoType === "master") { const [videoSegment, audioSegment] = yield* Effect.all([ - s3.listObjects({ prefix: videoPrefix, maxKeys: 1 }), - s3.listObjects({ prefix: audioPrefix, maxKeys: 1 }), + bucket.listObjects({ prefix: videoPrefix, maxKeys: 1 }), + bucket.listObjects({ prefix: audioPrefix, maxKeys: 1 }), ]); const videoSegmentKey = videoSegment.Contents?.[0]?.Key; @@ -364,11 +364,11 @@ const getPlaylistResponse = ( return yield* Effect.fail(new HttpApiError.NotFound()); } - const videoMetadata = yield* s3.headObject(videoSegmentKey); + const videoMetadata = yield* bucket.headObject(videoSegmentKey); const audioMetadata = audioSegment?.KeyCount && audioSegment.KeyCount > 0 ? audioSegment.Contents?.[0]?.Key - ? yield* s3.headObject(audioSegment.Contents[0].Key) + ? yield* bucket.headObject(audioSegment.Contents[0].Key) : undefined : undefined; @@ -400,7 +400,7 @@ const getPlaylistResponse = ( return yield* Effect.fail(new HttpApiError.NotFound()); } - const objects = yield* s3.listObjects({ + const objects = yield* bucket.listObjects({ prefix, maxKeys: urlParams.thumbnail ? 1 : undefined, }); @@ -408,8 +408,8 @@ const getPlaylistResponse = ( const chunksUrls = yield* Effect.all( (objects.Contents || []).map((object) => Effect.gen(function* () { - const url = yield* s3.getSignedObjectUrl(object.Key ?? ""); - const metadata = yield* s3.headObject(object.Key ?? ""); + const url = yield* bucket.getSignedObjectUrl(object.Key ?? ""); + const metadata = yield* bucket.headObject(object.Key ?? ""); return { url: url, diff --git a/apps/web/app/api/storage/object/route.ts b/apps/web/app/api/storage/object/route.ts new file mode 100644 index 0000000000..8ec8f88f4c --- /dev/null +++ b/apps/web/app/api/storage/object/route.ts @@ -0,0 +1,155 @@ +import { + provideOptionalAuth, + Storage, + Videos, + VideosRepo, + verifyStorageObjectToken, +} from "@cap/web-backend"; +import { Storage as StorageDomain, Video } from "@cap/web-domain"; +import { Effect, Option } from "effect"; +import type { NextRequest } from "next/server"; +import { runPromise } from "@/lib/server"; +import { CACHE_CONTROL_HEADERS } from "@/utils/helpers"; + +export const dynamic = "force-dynamic"; + +const copyHeader = ( + source: Headers, + target: Headers, + sourceName: string, + targetName = sourceName, +) => { + const value = source.get(sourceName); + if (value) target.set(targetName, value); +}; + +const asRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null + ? (value as Record) + : null; + +const getErrorTag = (error: unknown) => { + const record = asRecord(error); + const tag = record?._tag; + return typeof tag === "string" ? tag : null; +}; + +const getErrorText = (error: unknown): string => { + if (error instanceof Error) { + const record = asRecord(error); + const cause = record?.cause; + if (cause !== undefined && cause !== error) { + return `${error.message} ${getErrorText(cause)}`; + } + return error.message; + } + if (typeof error === "string") return error; + const record = asRecord(error); + const cause = record?.cause; + if (cause !== undefined && cause !== error) return getErrorText(cause); + return String(error); +}; + +const isStorageError = (error: unknown) => + error instanceof StorageDomain.StorageError || + getErrorTag(error) === "StorageError"; + +const isPolicyDeniedError = (error: unknown) => + getErrorTag(error) === "PolicyDenied"; + +const isObjectNotFoundError = (error: unknown) => { + if (error === "not-found") return true; + if (!isStorageError(error)) return false; + const message = getErrorText(error); + return ( + message.includes("Storage object not found") || + message.includes("Google Drive object not found") || + message.includes("Google Drive request failed: 404") + ); +}; + +const toProxyErrorResponse = (error: unknown) => { + if (isObjectNotFoundError(error) || isPolicyDeniedError(error)) { + return new Response("Not found", { status: 404 }); + } + if (isStorageError(error)) { + return new Response("Storage upstream error", { status: 502 }); + } + return new Response("Internal server error", { status: 500 }); +}; + +const getTokenVideo = (videoId: Video.VideoId) => + Effect.gen(function* () { + const repo = yield* VideosRepo; + const maybeVideo = yield* repo.getById(videoId); + if (Option.isNone(maybeVideo)) return yield* Effect.fail("not-found"); + return maybeVideo.value[0]; + }); + +const getPolicyVideo = (videoId: Video.VideoId) => + Effect.gen(function* () { + const videos = yield* Videos; + const maybeVideo = yield* videos.getByIdForViewing(videoId).pipe( + Effect.flatten, + Effect.catchTag("NoSuchElementException", () => + Effect.fail("not-found" as const), + ), + ); + return maybeVideo[0]; + }); + +export async function GET(request: NextRequest) { + const videoIdParam = request.nextUrl.searchParams.get("videoId"); + const key = request.nextUrl.searchParams.get("key"); + const token = request.nextUrl.searchParams.get("token"); + + if (!videoIdParam || !key) { + return new Response("Missing videoId or key", { status: 400 }); + } + + const effect = Effect.gen(function* () { + const tokenPayload = token ? verifyStorageObjectToken(token) : null; + const videoId = Video.VideoId.make(videoIdParam); + const video = + tokenPayload?.videoId === videoIdParam && tokenPayload.key === key + ? yield* getTokenVideo(videoId) + : yield* getPolicyVideo(videoId); + + if (!key.startsWith(`${video.ownerId}/${video.id}/`)) { + return yield* Effect.fail("not-found" as const); + } + + const [storage] = yield* Storage.getAccessForVideo(video); + if (!("getObjectResponse" in storage)) { + const url = yield* storage.getSignedObjectUrl(key); + return Response.redirect(url); + } + + const upstream = yield* storage.getObjectResponse( + key, + request.headers.get("range"), + ); + const headers = new Headers(CACHE_CONTROL_HEADERS); + copyHeader(upstream.headers, headers, "content-type", "Content-Type"); + copyHeader(upstream.headers, headers, "content-length", "Content-Length"); + copyHeader(upstream.headers, headers, "content-range", "Content-Range"); + copyHeader(upstream.headers, headers, "accept-ranges", "Accept-Ranges"); + if (!headers.has("Accept-Ranges")) headers.set("Accept-Ranges", "bytes"); + + return new Response(upstream.body, { + status: upstream.status, + headers, + }); + }).pipe( + provideOptionalAuth, + Effect.catchAll((error) => Effect.succeed(toProxyErrorResponse(error))), + ); + + try { + return await runPromise(effect); + } catch (error) { + return toProxyErrorResponse(error); + } +} + +export const HEAD = GET; diff --git a/apps/web/app/api/thumbnail/route.ts b/apps/web/app/api/thumbnail/route.ts index f17c625323..a42e5c8fe7 100644 --- a/apps/web/app/api/thumbnail/route.ts +++ b/apps/web/app/api/thumbnail/route.ts @@ -1,11 +1,11 @@ import { db } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; +import { videos } from "@cap/database/schema"; +import { Storage } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Option } from "effect"; import type { NextRequest } from "next/server"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; import { getHeaders } from "@/utils/helpers"; export async function GET(request: NextRequest) { @@ -26,12 +26,8 @@ export async function GET(request: NextRequest) { ); const [query] = await db() - .select({ - video: videos, - bucket: s3Buckets, - }) + .select() .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .where(eq(videos.id, Video.VideoId.make(videoId))); if (!query) @@ -43,12 +39,12 @@ export async function GET(request: NextRequest) { }, ); - const prefix = `${query.video.ownerId}/${query.video.id}/`; + const video = decodeStorageVideo(query); + + const prefix = `${video.ownerId}/${video.id}/`; try { - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(query.bucket?.id), - ).pipe(runPromise); + const [bucket] = await Storage.getAccessForVideo(video).pipe(runPromise); const listResponse = await bucket .listObjects({ prefix: prefix }) diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index 65871e7522..c405ab1c73 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -5,7 +5,7 @@ import { Database, makeCurrentUserLayer, provideOptionalAuth, - S3Buckets, + Storage, VideosPolicy, VideosRepo, } from "@cap/web-backend"; @@ -16,6 +16,7 @@ import { Effect, Option, Schedule } from "effect"; import { Hono, type MiddlewareHandler } from "hono"; import { z } from "zod"; import { withAuth } from "@/app/api/utils"; +import { invalidateGoogleDriveStorageQuotaCache } from "@/lib/google-drive-storage-quota"; import { runPromise } from "@/lib/server"; import { startVideoProcessingWorkflow } from "@/lib/video-processing"; import { stringOrNumberOptional } from "@/utils/zod"; @@ -121,7 +122,16 @@ app.post( try { try { const uploadId = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccessForUser(user.id); + const repo = yield* VideosRepo; + const policy = yield* VideosPolicy; + const maybeVideo = yield* repo + .getById(videoId) + .pipe(Policy.withPolicy(policy.isOwner(videoId))); + if (Option.isNone(maybeVideo)) { + return yield* new Video.NotFoundError(); + } + const [video] = maybeVideo.value; + const [bucket] = yield* Storage.getAccessForVideo(video); const finalContentType = contentType || "video/mp4"; console.log( @@ -148,10 +158,14 @@ app.post( `Upload details: Bucket=${bucket.bucketName}, Key=${fileKey}, ContentType=${finalContentType}`, ); - return UploadId; - }).pipe(provideOptionalAuth, runPromiseAnyEnv); + return { uploadId: UploadId, provider: bucket.provider }; + }).pipe( + Effect.provide(makeCurrentUserLayer(user)), + provideOptionalAuth, + runPromiseAnyEnv, + ); - return c.json({ uploadId: uploadId }); + return c.json(uploadId); } catch (s3Error) { console.error("S3 operation failed:", s3Error); throw new Error( @@ -201,7 +215,21 @@ app.post( try { try { const presignedUrl = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccessForUser(user.id); + const videoIdFromFileKey = fileKey.split("/")[1]; + const videoIdRaw = + "videoId" in body ? body.videoId : videoIdFromFileKey; + if (!videoIdRaw) throw new Error("Video id not found"); + const videoId = Video.VideoId.make(videoIdRaw); + const repo = yield* VideosRepo; + const policy = yield* VideosPolicy; + const maybeVideo = yield* repo + .getById(videoId) + .pipe(Policy.withPolicy(policy.isOwner(videoId))); + if (Option.isNone(maybeVideo)) { + return yield* new Video.NotFoundError(); + } + const [video] = maybeVideo.value; + const [bucket] = yield* Storage.getAccessForVideo(video); console.log( `Getting presigned URL for part ${partNumber} of upload ${uploadId}`, @@ -215,10 +243,14 @@ app.post( { ContentMD5: body.md5Sum }, ); - return presignedUrl; - }).pipe(provideOptionalAuth, runPromiseAnyEnv); + return { presignedUrl, provider: bucket.provider }; + }).pipe( + Effect.provide(makeCurrentUserLayer(user)), + provideOptionalAuth, + runPromiseAnyEnv, + ); - return c.json({ presignedUrl }); + return c.json(presignedUrl); } catch (s3Error) { console.error("S3 operation failed:", s3Error); throw new Error( @@ -294,7 +326,7 @@ app.post( const [video] = maybeVideo.value; return yield* Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId); + const [bucket] = yield* Storage.getAccessForVideo(video); const { result, formattedParts } = yield* Effect.gen(function* () { console.log( @@ -341,7 +373,15 @@ app.post( MultipartUpload: { Parts: formattedParts, }, + ...(bucket.provider === "googleDrive" + ? { MpuObjectSize: totalSize } + : {}), }); + yield* Effect.promise(() => + invalidateGoogleDriveStorageQuotaCache( + Option.getOrNull(video.storageIntegrationId), + ), + ); return { result, formattedParts }; }); @@ -419,30 +459,32 @@ app.post( }); } - console.log( - "Performing metadata fix by copying the object to itself...", - ); + if (bucket.provider === "s3") { + console.log( + "Performing metadata fix by copying the object to itself...", + ); - yield* bucket - .copyObject(`${bucket.bucketName}/${fileKey}`, fileKey, { - ContentType: "video/mp4", - MetadataDirective: "REPLACE", - }) - .pipe( - Effect.tap((result) => - Effect.log("Copy for metadata fix successful:", result), - ), - Effect.catchAll((e) => - Effect.logError( - "Warning: Failed to copy object to fix metadata:", - e, + yield* bucket + .copyObject(`${bucket.bucketName}/${fileKey}`, fileKey, { + ContentType: "video/mp4", + MetadataDirective: "REPLACE", + }) + .pipe( + Effect.tap((result) => + Effect.log("Copy for metadata fix successful:", result), ), - ), - Effect.retry({ - times: 3, - schedule: Schedule.exponential("50 millis"), - }), - ); + Effect.catchAll((e) => + Effect.logError( + "Warning: Failed to copy object to fix metadata:", + e, + ), + ), + Effect.retry({ + times: 3, + schedule: Schedule.exponential("50 millis"), + }), + ); + } yield* db.use((db) => db.transaction(() => @@ -474,7 +516,11 @@ app.post( ); const mediaServerUrl = serverEnv().MEDIA_SERVER_URL; - if (video.source.type === "webMP4" && mediaServerUrl) { + if ( + bucket.provider === "s3" && + video.source.type === "webMP4" && + mediaServerUrl + ) { const inputUrl = yield* bucket.getInternalSignedObjectUrl(fileKey); const outputPresignedUrl = yield* bucket.getInternalPresignedPutUrl( fileKey, @@ -598,7 +644,7 @@ app.post("/abort", abortRequestValidator, (c) => { } const [video] = maybeVideo.value; - const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId); + const [bucket] = yield* Storage.getAccessForVideo(video); type MultipartWithAbort = typeof bucket.multipart & { abort: ( ...args: Parameters diff --git a/apps/web/app/api/upload/[...route]/recording-complete.ts b/apps/web/app/api/upload/[...route]/recording-complete.ts index ce5515b9a6..d8e8afd8cb 100644 --- a/apps/web/app/api/upload/[...route]/recording-complete.ts +++ b/apps/web/app/api/upload/[...route]/recording-complete.ts @@ -1,14 +1,16 @@ import { db } from "@cap/database"; import * as Db from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; -import { S3Bucket, Video } from "@cap/web-domain"; +import { Storage } from "@cap/web-backend"; +import { Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, eq, notInArray } from "drizzle-orm"; import { Effect, Option, Schema } from "effect"; import { Hono } from "hono"; import { z } from "zod"; +import { invalidateGoogleDriveStorageQuotaCache } from "@/lib/google-drive-storage-quota"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; import { withAuth } from "../../utils"; export const app = new Hono().post( @@ -48,16 +50,16 @@ export const app = new Hono().post( await db() .delete(Db.videoUploads) .where(eq(Db.videoUploads.videoId, videoId)); + await invalidateGoogleDriveStorageQuotaCache(video.storageIntegrationId); return c.json({ success: true }); } try { const muxPayload = await Effect.gen(function* () { - const bucketId = Option.fromNullable(video.bucket).pipe( - Option.map(S3Bucket.S3BucketId.make), + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(video), ); - const [bucket] = yield* S3Buckets.getBucketAccess(bucketId); const segSource = new Video.SegmentsSource({ videoId: videoIdRaw, @@ -70,7 +72,7 @@ export const app = new Hono().post( Effect.andThen( Option.match({ onNone: () => - Effect.fail(new Error("Segment manifest not found on S3")), + Effect.fail(new Error("Segment manifest not found")), onSome: (c) => Effect.succeed(c), }), ), @@ -164,6 +166,7 @@ export const app = new Hono().post( audioSegmentUrls, }; }).pipe(runPromise); + await invalidateGoogleDriveStorageQuotaCache(video.storageIntegrationId); const claimResult = await db() .update(Db.videoUploads) diff --git a/apps/web/app/api/upload/[...route]/signed.ts b/apps/web/app/api/upload/[...route]/signed.ts index 420ef1e115..63224b0f7e 100644 --- a/apps/web/app/api/upload/[...route]/signed.ts +++ b/apps/web/app/api/upload/[...route]/signed.ts @@ -1,20 +1,23 @@ -import type { PresignedPost } from "@aws-sdk/s3-presigned-post"; import { db, updateIfDefined } from "@cap/database"; import * as Db from "@cap/database/schema"; -import { S3Buckets } from "@cap/web-backend"; +import { Storage } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Effect } from "effect"; import { Hono } from "hono"; import { z } from "zod"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; import { isFromDesktopSemver, UPLOAD_PROGRESS_VERSION } from "@/utils/desktop"; import { stringOrNumberOptional } from "@/utils/zod"; import { withAuth } from "../../utils"; import { parseVideoIdOrFileKey } from "../utils"; +const decodeVideo = (video: typeof Db.videos.$inferSelect) => + decodeStorageVideo(video); + function contentTypeForSubpath(subpath: string): string { if (subpath.endsWith(".json")) return "application/json"; if (subpath.endsWith(".mp4") || subpath.endsWith(".m4s")) return "video/mp4"; @@ -52,34 +55,39 @@ app.post( const { videoId, subpaths } = c.req.valid("json"); try { - const [customBucket] = await db() + const [video] = await db() .select() - .from(Db.s3Buckets) - .where(eq(Db.s3Buckets.ownerId, user.id)); + .from(Db.videos) + .where(eq(Db.videos.id, Video.VideoId.make(videoId))); - const urls = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(customBucket?.id), - ); + if (!video) return c.json({ error: "Video not found" }, 404); + if (video.ownerId !== user.id) return c.json({ error: "Forbidden" }, 403); + const videoDomain = decodeVideo(video); + + const batch = await Effect.gen(function* () { + const [bucket] = yield* Storage.getAccessForVideo(videoDomain); const entries = yield* Effect.all( subpaths.map((subpath) => { const fileKey = `${user.id}/${videoId}/${subpath}`; return bucket - .getPresignedPutUrl( - fileKey, - { ContentType: contentTypeForSubpath(subpath) }, - { expiresIn: 1800 }, - ) - .pipe(Effect.map((url) => [subpath, url] as const)); + .createUploadTarget(fileKey, { + contentType: contentTypeForSubpath(subpath), + method: "put", + }) + .pipe(Effect.map((upload) => [subpath, upload] as const)); }), { concurrency: "unbounded" }, ); - return Object.fromEntries(entries); + const uploads = Object.fromEntries(entries); + const urls = Object.fromEntries( + entries.map(([subpath, upload]) => [subpath, upload.url]), + ); + return { uploads, urls }; }).pipe(runPromise); - return c.json({ urls }); + return c.json(batch); } catch (error) { console.error("Batch signed URL generation failed:", error); return c.json({ error: "Internal server error" }, 500); @@ -113,12 +121,19 @@ app.post( c.req.valid("json"); const fileKey = parseVideoIdOrFileKey(user.id, body); + const videoIdFromKey = fileKey.split("/")[1]; + const videoIdToUse = "videoId" in body ? body.videoId : videoIdFromKey; + if (!videoIdToUse) return c.json({ error: "Video id not found" }, 400); try { - const [customBucket] = await db() + const [video] = await db() .select() - .from(Db.s3Buckets) - .where(eq(Db.s3Buckets.ownerId, user.id)); + .from(Db.videos) + .where(eq(Db.videos.id, Video.VideoId.make(videoIdToUse))); + + if (!video) return c.json({ error: "Video not found" }, 404); + if (video.ownerId !== user.id) return c.json({ error: "Forbidden" }, 403); + const videoDomain = decodeVideo(video); const contentType = fileKey.endsWith(".aac") ? "audio/aac" @@ -133,45 +148,24 @@ app.post( : "video/mp2t"; const data = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(customBucket?.id), - ); - - if (method === "post") { - const Fields = { - "Content-Type": contentType, - "x-amz-meta-userid": user.id, - "x-amz-meta-duration": durationInSecs - ? durationInSecs.toString() - : "", - }; - - return yield* bucket.getPresignedPostUrl(fileKey, { - Fields, - Expires: 1800, - }); - } - - const presignedUrl = yield* bucket.getPresignedPutUrl( - fileKey, - { - ContentType: contentType, - Metadata: { - userid: user.id, - duration: durationInSecs ? durationInSecs.toString() : "", - }, - }, - { expiresIn: 1800 }, - ); - - return { url: presignedUrl, fields: {} } satisfies PresignedPost; + const [bucket] = yield* Storage.getAccessForVideo(videoDomain); + + const Fields = { + "x-amz-meta-userid": user.id, + "x-amz-meta-duration": durationInSecs + ? durationInSecs.toString() + : "", + }; + + return yield* bucket.createUploadTarget(fileKey, { + contentType, + fields: Fields, + method, + }); }).pipe(runPromise); console.log("Presigned URL created successfully"); - const videoIdFromKey = fileKey.split("/")[1]; - - const videoIdToUse = "videoId" in body ? body.videoId : videoIdFromKey; if (videoIdToUse) { const videoId = Video.VideoId.make(videoIdToUse); await db() @@ -197,8 +191,19 @@ app.post( .where(eq(Db.videoUploads.videoId, videoId)); } - if (method === "post") return c.json({ presignedPostData: data }); - else return c.json({ presignedPutData: data }); + if (data.type === "s3Post") { + return c.json({ + presignedPostData: { url: data.url, fields: data.fields }, + }); + } + return c.json({ + presignedPutData: { + url: data.url, + fields: {}, + headers: data.headers, + type: data.type, + }, + }); } catch (s3Error) { console.error("S3 operation failed:", s3Error); throw new Error( diff --git a/apps/web/app/api/video/delete/route.ts b/apps/web/app/api/video/delete/route.ts index d358a1a7b8..b370e7fbbe 100644 --- a/apps/web/app/api/video/delete/route.ts +++ b/apps/web/app/api/video/delete/route.ts @@ -35,7 +35,7 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( Effect.logError(e).pipe( Effect.andThen(() => new HttpApiError.InternalServerError()), ), - S3Error: (e) => + StorageError: (e) => Effect.logError(e).pipe( Effect.andThen(() => new HttpApiError.InternalServerError()), ), diff --git a/apps/web/app/api/webhooks/media-server/progress/route.ts b/apps/web/app/api/webhooks/media-server/progress/route.ts index 10d4d0c706..aa18905f12 100644 --- a/apps/web/app/api/webhooks/media-server/progress/route.ts +++ b/apps/web/app/api/webhooks/media-server/progress/route.ts @@ -1,12 +1,14 @@ import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; -import type { S3Bucket, Video } from "@cap/web-domain"; +import { Storage } from "@cap/web-backend"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Effect } from "effect"; import { type NextRequest, NextResponse } from "next/server"; +import { invalidateGoogleDriveStorageQuotaCache } from "@/lib/google-drive-storage-quota"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; interface ProgressWebhookPayload { jobId: string; @@ -97,11 +99,7 @@ export async function POST(request: NextRequest) { } const [currentVideo] = await db() - .select({ - source: videos.source, - ownerId: videos.ownerId, - bucket: videos.bucket, - }) + .select() .from(videos) .where(eq(videos.id, payload.videoId as Video.VideoId)); @@ -117,10 +115,9 @@ export async function POST(request: NextRequest) { if (ownerId) { const segmentsPrefix = `${ownerId}/${videoId}/segments/`; Effect.gen(function* () { - const bucketId = Option.fromNullable( - currentVideo.bucket, - ) as Option.Option; - const [bucket] = yield* S3Buckets.getBucketAccess(bucketId); + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(currentVideo), + ); let totalDeleted = 0; let continuationToken: string | undefined; @@ -164,6 +161,9 @@ export async function POST(request: NextRequest) { await db() .delete(videoUploads) .where(eq(videoUploads.videoId, payload.videoId as Video.VideoId)); + await invalidateGoogleDriveStorageQuotaCache( + currentVideo?.storageIntegrationId, + ); } else if (dbPhase === "error") { await db() .update(videoUploads) diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx index ee56bf3e67..7b88dc53ba 100644 --- a/apps/web/app/embed/[videoId]/page.tsx +++ b/apps/web/app/embed/[videoId]/page.tsx @@ -128,6 +128,7 @@ export default async function EmbedVideoPage( effectiveCreatedAt: videos.effectiveCreatedAt, updatedAt: videos.updatedAt, bucket: videos.bucket, + storageIntegrationId: videos.storageIntegrationId, metadata: videos.metadata, public: videos.public, videoStartTime: videos.videoStartTime, diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index 7cd8f07229..f31f5c086c 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -16,7 +16,7 @@ import type { VideoData } from "../types"; import { type CaptionLanguage, useCaptionContext } from "./CaptionContext"; import { CapVideoPlayer } from "./CapVideoPlayer"; import { HLSVideoPlayer } from "./HLSVideoPlayer"; -import { useUploadProgress } from "./ProgressCircle"; +import { shouldDeferPlaybackSource, useUploadProgress } from "./ProgressCircle"; import { PreparingVideoOverlay, RecordingInProgressOverlay, @@ -200,7 +200,7 @@ export const ShareVideo = forwardRef< isSegmentsSource && (data.hasActiveUpload ?? false) && !isActivelyRecording && - segmentUploadProgress !== null; + shouldDeferPlaybackSource(segmentUploadProgress); const prevProgressRef = useRef( segmentUploadProgress, diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 8741a78e83..015268df84 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -311,6 +311,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { updatedAt: videos.updatedAt, effectiveCreatedAt: videos.effectiveCreatedAt, bucket: videos.bucket, + storageIntegrationId: videos.storageIntegrationId, metadata: videos.metadata, public: videos.public, videoStartTime: videos.videoStartTime, diff --git a/apps/web/components/pages/HomePage/Bento.tsx b/apps/web/components/pages/HomePage/Bento.tsx new file mode 100644 index 0000000000..dbc9708629 --- /dev/null +++ b/apps/web/components/pages/HomePage/Bento.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Button } from "@cap/ui"; +import clsx from "clsx"; +import { motion } from "framer-motion"; +import type { ComponentType, ReactNode } from "react"; +import { homepageCopy } from "../../../data/homepage-copy"; +import AsyncCommentsArt from "./bento/AsyncCommentsArt"; +import CapAIArt from "./bento/CapAIArt"; +import NativePerformanceArt from "./bento/NativePerformanceArt"; +import OpenSourceArt from "./bento/OpenSourceArt"; +import PixelPerfectArt from "./bento/PixelPerfectArt"; +import StorageRoutingArt from "./bento/StorageRoutingArt"; + +type ArtComponent = ComponentType<{ className?: string }>; + +type LayoutClass = string; + +interface CardConfig { + key: string; + art: ArtComponent; + span: LayoutClass; + artHeight: LayoutClass; +} + +const CARD_CONFIG: CardConfig[] = [ + { + key: "storage", + art: StorageRoutingArt, + span: "md:col-span-4", + artHeight: "h-[260px] md:h-[300px]", + }, + { + key: "ai", + art: CapAIArt, + span: "md:col-span-2", + artHeight: "h-[260px] md:h-[300px]", + }, + { + key: "async", + art: AsyncCommentsArt, + span: "md:col-span-3", + artHeight: "h-[240px]", + }, + { + key: "native", + art: NativePerformanceArt, + span: "md:col-span-3", + artHeight: "h-[240px]", + }, + { + key: "oss", + art: OpenSourceArt, + span: "md:col-span-2", + artHeight: "h-[240px]", + }, + { + key: "pixel", + art: PixelPerfectArt, + span: "md:col-span-4", + artHeight: "h-[240px]", + }, +]; + +const cardVariants = { + hidden: { opacity: 0, y: 24 }, + visible: (custom: number) => ({ + opacity: 1, + y: 0, + transition: { + delay: custom * 0.06, + duration: 0.55, + ease: [0.22, 1, 0.36, 1], + }, + }), +}; + +interface BentoCardProps { + title: string; + description: string; + span: LayoutClass; + artHeight: LayoutClass; + children: ReactNode; + index: number; +} + +const BentoCard = ({ + title, + description, + span, + artHeight, + children, + index, +}: BentoCardProps) => ( + +
+ {children} +
+
+

{title}

+

+ {description} +

+
+
+); + +const Bento = () => { + const { eyebrow, title, subtitle, cards, cta } = homepageCopy.bento; + + return ( +
+ + + {eyebrow} + +

+ {title} +

+

+ {subtitle} +

+
+ + + {cards.map((card, i) => { + const config = CARD_CONFIG.find((c) => c.key === card.key); + if (!config) return null; + const Art = config.art; + return ( + + + + ); + })} + + +
+ +
+
+ ); +}; + +export default Bento; diff --git a/apps/web/components/pages/HomePage/Features.tsx b/apps/web/components/pages/HomePage/Features.tsx deleted file mode 100644 index 68776be986..0000000000 --- a/apps/web/components/pages/HomePage/Features.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { Button } from "@cap/ui"; -import { Fit, Layout, useRive } from "@rive-app/react-canvas"; -import clsx from "clsx"; -import { type JSX, memo } from "react"; -import { homepageCopy } from "../../../data/homepage-copy"; - -type Feature = { - title: string; - description: string; - rive: JSX.Element; - relative?: { - top?: number; - bottom?: number; - left?: number; - right?: number; - }; -}; - -const VideoCaptureArt = memo(() => { - const { RiveComponent: VideoCaptureRive } = useRive({ - src: "/rive/bento.riv", - artboard: "videocapture", - animations: ["in"], - autoplay: true, - layout: new Layout({ - fit: Fit.Contain, - }), - }); - return ( - - ); -}); - -const StorageOptionsArt = memo(() => { - const { RiveComponent: StorageOptionsRive } = useRive({ - src: "/rive/bento.riv", - artboard: "storageoptions", - animations: ["in"], - autoplay: true, - layout: new Layout({ - fit: Fit.Contain, - }), - }); - return ( - - ); -}); - -const CollabArt = memo(() => { - const { RiveComponent: CollabRive } = useRive({ - src: "/rive/bento.riv", - artboard: "collab", - animations: ["in"], - autoplay: true, - layout: new Layout({ - fit: Fit.Contain, - }), - }); - return ; -}); - -const PrivacyFirstArt = memo(() => { - const { RiveComponent: PrivacyFirstRive } = useRive({ - src: "/rive/bento.riv", - artboard: "privacyfirst", - animations: ["in"], - autoplay: true, - layout: new Layout({ - fit: Fit.Contain, - }), - }); - return ( - - ); -}); - -const PlatformSupportArt = memo(() => { - const { RiveComponent: PlatformSupportRive } = useRive({ - src: "/rive/bento.riv", - artboard: "platformsupport", - animations: ["in"], - autoplay: true, - layout: new Layout({ - fit: Fit.Contain, - }), - }); - return ( - - ); -}); - -const EveryoneArt = memo(() => { - const { RiveComponent: EveryoneRive } = useRive({ - src: "/rive/bento.riv", - artboard: "everyone", - animations: ["in"], - autoplay: true, - layout: new Layout({ - fit: Fit.Contain, - }), - }); - return ; -}); - -const CapAIArt = memo(() => { - const { RiveComponent: CapAIArt } = useRive({ - src: "/rive/bento.riv", - artboard: "capai", - animations: ["in"], - autoplay: true, - layout: new Layout({ - fit: Fit.Contain, - }), - }); - return ; -}); - -const features: Feature[] = homepageCopy.features.features.map( - (feature, index) => { - const riveComponents: JSX.Element[] = [ - , - , - , - , - , - , - , - ]; - - const relatives = [ - { top: 25 }, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ]; - - return { - title: feature.title, - description: feature.description, - rive: riveComponents[index] ||
, - relative: relatives[index], - }; - }, -); - -const Features = () => { - return ( -
-

- {homepageCopy.features.title} -

-

- {homepageCopy.features.subtitle} -

-
- {/* Second row - 2 features */} -
- {features.slice(3, 5).map((feature) => ( - - ))} -
- - {/* First row - 3 features */} -
- {features.slice(0, 3).map((feature) => ( - - ))} -
- - {/* Third row - 2 features */} -
- {features.slice(5, 7).map((feature) => ( - - ))} -
-
- -
- {/* View all features button */} - -
-
- ); -}; - -const FeatureCard = ({ - title, - description, - rive, - relative, - className, -}: { - title: string; - description: string; - rive?: JSX.Element; - img?: string; - className?: string; - imageAlt: string; - imageClass?: string; - relative?: { - top?: number; - bottom?: number; - left?: number; - right?: number; - }; -}) => { - return ( -
-
- {rive} -
-
-

{title}

-

{description}

-
-
- ); -}; - -export default Features; diff --git a/apps/web/components/pages/HomePage/bento/AsyncCommentsArt.tsx b/apps/web/components/pages/HomePage/bento/AsyncCommentsArt.tsx new file mode 100644 index 0000000000..23114c8c9a --- /dev/null +++ b/apps/web/components/pages/HomePage/bento/AsyncCommentsArt.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; + +interface ArtProps { + className?: string; +} + +const CYCLE_MS = 5000; + +const MARKERS = [ + { + pct: 0.25, + name: "Sarah M", + text: "Love this approach 🔥", + emoji: "❤️", + color: "from-pink-400 to-rose-500", + yOffset: -110, + }, + { + pct: 0.55, + name: "Jamie L", + text: "Can we extract this into a hook?", + emoji: "🔥", + color: "from-violet-400 to-purple-500", + yOffset: -80, + }, + { + pct: 0.8, + name: "Alex K", + text: "Shipped! 🚀", + emoji: "👏", + color: "from-blue-400 to-cyan-500", + yOffset: -100, + }, +]; + +const WAVEFORM = Array.from({ length: 48 }, (_, i) => { + const base = + Math.sin(i * 0.7) * 0.4 + + Math.sin(i * 1.3) * 0.3 + + Math.sin(i * 2.1) * 0.2 + + 0.1; + return { id: `wb${i}`, height: Math.max(0.08, Math.min(1, Math.abs(base))) }; +}); + +interface CommentBubbleProps { + name: string; + text: string; + emoji: string; + color: string; + yOffset: number; +} + +function CommentBubble({ + name, + text, + emoji, + color, + yOffset, +}: CommentBubbleProps) { + return ( + +
+
+

+ {name} +

+

{text}

+
+ + + ); +} + +function FloatingEmoji({ emoji }: { emoji: string }) { + return ( + + {emoji} + + ); +} + +const AsyncCommentsArt = ({ className }: ArtProps) => { + const prefersReduced = useReducedMotion(); + const [progress, setProgress] = useState(0); + const [cycle, setCycle] = useState(0); + const [commentCount, setCommentCount] = useState(3); + const [reactionCount, setReactionCount] = useState(12); + const rafRef = useRef(null); + const startRef = useRef(null); + + useEffect(() => { + if (prefersReduced) return; + + function tick(ts: number) { + if (startRef.current === null) startRef.current = ts; + const elapsed = ts - startRef.current; + const p = Math.min(elapsed / CYCLE_MS, 1); + setProgress(p); + if (p < 1) { + rafRef.current = requestAnimationFrame(tick); + } else { + setTimeout(() => { + startRef.current = null; + setProgress(0); + setCycle((c) => c + 1); + setCommentCount((c) => c + Math.floor(Math.random() * 2)); + setReactionCount((r) => r + Math.floor(Math.random() * 4 + 1)); + rafRef.current = requestAnimationFrame(tick); + }, 400); + } + } + + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, [prefersReduced]); + + const visibleMarkers = MARKERS.filter( + (m) => prefersReduced || progress >= m.pct, + ); + + return ( +
+
+ + {commentCount} comments • {reactionCount} reactions + +
+ +
+ {WAVEFORM.map((bar) => ( +
+ ))} + + {MARKERS.map((m) => ( +
+ ))} + + +
+
+ +
+ +
+ + {visibleMarkers.map((m) => ( +
+ +
+ ))} +
+
+
+ ); +}; + +export default AsyncCommentsArt; diff --git a/apps/web/components/pages/HomePage/bento/CapAIArt.tsx b/apps/web/components/pages/HomePage/bento/CapAIArt.tsx new file mode 100644 index 0000000000..cae61c1e59 --- /dev/null +++ b/apps/web/components/pages/HomePage/bento/CapAIArt.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { BookOpen, Captions, FileText, Sparkles, Type } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +const STATES = ["title", "summary", "chapters", "transcript"] as const; + +const TITLE_TEXT = "How to ship a feature in 24 hours"; + +const SUMMARY_LINES = [ + "Walks through a complete feature lifecycle", + "From initial spec to production deploy", + "Includes testing, review & rollout steps", +]; + +const CHAPTERS = [ + { time: "0:00", label: "Defining the scope" }, + { time: "1:24", label: "Writing the spec" }, + { time: "3:47", label: "Building & testing" }, +]; + +const TRANSCRIPT_ROWS = [ + { time: "0:12", text: "Let's start by scoping the feature clearly" }, + { time: "0:28", text: "so the team knows exactly what to build" }, + { time: "0:41", text: "and what to leave for a future iteration." }, + { time: "0:55", text: "First, write a one-pager with the goal" }, + { time: "1:08", text: "success criteria and non-goals defined." }, +]; + +function TitleCard({ reduced }: { reduced: boolean }) { + const [displayed, setDisplayed] = useState(reduced ? TITLE_TEXT : ""); + const idx = useRef(0); + + useEffect(() => { + if (reduced) return; + idx.current = 0; + setDisplayed(""); + const id = setInterval(() => { + idx.current += 2; + setDisplayed(TITLE_TEXT.slice(0, idx.current)); + if (idx.current >= TITLE_TEXT.length) clearInterval(id); + }, 25); + return () => clearInterval(id); + }, [reduced]); + + return ( +
+
+ + Title +
+

+ {displayed} + {!reduced && displayed.length < TITLE_TEXT.length && ( + + )} +

+
+ ); +} + +function SummaryCard() { + return ( +
+
+ + Summary +
+
+ {SUMMARY_LINES.map((line, i) => ( + +

+ {line} +

+
+ ))} +
+
+ ); +} + +function ChaptersCard() { + return ( +
+
+ + Chapters +
+
+ {CHAPTERS.map((ch, i) => ( + + + {ch.time} + + + {ch.label} + + + ))} +
+
+ ); +} + +function TranscriptCard() { + return ( +
+
+ + Transcript +
+ + {TRANSCRIPT_ROWS.map((row) => ( +
+ + {row.time} + + + {row.text} + +
+ ))} +
+
+ ); +} + +interface ArtProps { + className?: string; +} + +const CapAIArt = ({ className }: ArtProps) => { + const reduced = useReducedMotion() ?? false; + const [stateIdx, setStateIdx] = useState(0); + + useEffect(() => { + if (reduced) return; + const id = setInterval(() => { + setStateIdx((prev) => (prev + 1) % STATES.length); + }, 2200); + return () => clearInterval(id); + }, [reduced]); + + const current = STATES[stateIdx]; + + return ( +
+ + + +
+
+ + + + Cap AI +
+ +
+ + + {current === "title" && } + {current === "summary" && } + {current === "chapters" && } + {current === "transcript" && } + + +
+ +
+ {STATES.map((s, i) => ( +
+ ))} +
+
+
+ ); +}; + +export default CapAIArt; diff --git a/apps/web/components/pages/HomePage/bento/NativePerformanceArt.tsx b/apps/web/components/pages/HomePage/bento/NativePerformanceArt.tsx new file mode 100644 index 0000000000..8742b8bc7e --- /dev/null +++ b/apps/web/components/pages/HomePage/bento/NativePerformanceArt.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { motion, useReducedMotion } from "framer-motion"; +import { Activity, Globe, Zap } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface PerfValues { + capCpu: number; + capRam: number; + elCpu: number; + elRam: number; +} + +const STATIC: PerfValues = { + capCpu: 8, + capRam: 128, + elCpu: 72, + elRam: 680, +}; + +const RAM_MAX = 1024; + +function randomBetween(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function PerformanceBar({ + value, + max, + accent, +}: { + value: number; + max: number; + accent: "blue" | "muted"; +}) { + const pct = Math.min((value / max) * 100, 100); + return ( +
+ +
+ ); +} + +function StatRow({ + cpu, + ram, + accent, +}: { + cpu: number; + ram: number; + accent: "blue" | "muted"; +}) { + return ( +
+
+
+ CPU + + {cpu}% + +
+ +
+
+
+ RAM + + {ram} MB + +
+ +
+
+ ); +} + +export default function NativePerformanceArt({ + className, +}: { + className?: string; +}) { + const reduced = useReducedMotion(); + const [values, setValues] = useState(STATIC); + + useEffect(() => { + if (reduced) return; + + const id = setInterval(() => { + setValues({ + capCpu: randomBetween(4, 14), + capRam: randomBetween(110, 140), + elCpu: randomBetween(55, 85), + elRam: randomBetween(580, 720), + }); + }, 800); + + return () => clearInterval(id); + }, [reduced]); + + return ( +
+
+ + + Live performance + + {!reduced && ( + + )} + {reduced && ( +
+ )} +
+ +
+
+
+
+ +
+
+ + Cap + + + Native + +
+
+ +
+ +
+
+
+ +
+
+ + Electron App + + + Chromium + +
+
+ +
+
+
+ ); +} diff --git a/apps/web/components/pages/HomePage/bento/OpenSourceArt.tsx b/apps/web/components/pages/HomePage/bento/OpenSourceArt.tsx new file mode 100644 index 0000000000..b467b7ceb0 --- /dev/null +++ b/apps/web/components/pages/HomePage/bento/OpenSourceArt.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { + animate, + motion, + useMotionValue, + useReducedMotion, +} from "framer-motion"; +import { Github, Star } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +const ROWS = 7; +const COLS = 14; +const START_STARS = 9842; +const END_STARS = 10347; +const FILL_CLASSES = [ + "bg-emerald-200", + "bg-emerald-400", + "bg-emerald-500", + "bg-emerald-600", +] as const; + +const AVATAR_GRADIENTS = [ + "from-violet-400 to-blue-500", + "from-pink-400 to-rose-500", + "from-amber-400 to-orange-500", +]; + +function deterministicFill( + row: number, + col: number, +): (typeof FILL_CLASSES)[number] { + const hash = (row * 31 + col * 17 + row * col * 7) % FILL_CLASSES.length; + return FILL_CLASSES[hash] ?? FILL_CLASSES[0]; +} + +function deterministicActive(row: number, col: number): boolean { + const hash = (row * 13 + col * 29 + (row + col) * 3) % 10; + return hash > 2; +} + +function formatStars(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} + +interface CellData { + id: string; + row: number; + col: number; + active: boolean; + fill: (typeof FILL_CLASSES)[number]; +} + +interface ArtProps { + className?: string; +} + +const OpenSourceArt = ({ className }: ArtProps) => { + const shouldReduceMotion = useReducedMotion(); + const [step, setStep] = useState(shouldReduceMotion ? COLS : 0); + const intervalRef = useRef | null>(null); + const starCount = useMotionValue(START_STARS); + const [displayStars, setDisplayStars] = useState(START_STARS); + + const cellPattern = useMemo(() => { + return Array.from({ length: ROWS }, (_, r) => + Array.from({ length: COLS }, (_, c) => ({ + id: `cell-${r}-${c}`, + row: r, + col: c, + active: deterministicActive(r, c), + fill: deterministicFill(r, c), + })), + ); + }, []); + + useEffect(() => { + if (shouldReduceMotion) return; + + const unsubscribe = starCount.on("change", (v) => { + setDisplayStars(Math.round(v)); + }); + + return unsubscribe; + }, [starCount, shouldReduceMotion]); + + useEffect(() => { + if (shouldReduceMotion) return; + + const runStarAnimation = () => { + starCount.set(START_STARS); + animate(starCount, END_STARS, { duration: 4, ease: "easeOut" }); + }; + + runStarAnimation(); + const starInterval = setInterval(runStarAnimation, 6000); + + return () => clearInterval(starInterval); + }, [starCount, shouldReduceMotion]); + + useEffect(() => { + if (shouldReduceMotion) return; + + const STEP_INTERVAL = 150; + const HOLD_STEPS = 8; + + intervalRef.current = setInterval(() => { + setStep((prev) => { + if (prev >= COLS + HOLD_STEPS) return 0; + return prev + 1; + }); + }, STEP_INTERVAL); + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [shouldReduceMotion]); + + return ( +
+
+ + + github.com/CapSoftware/Cap + +
+ + + {formatStars(shouldReduceMotion ? END_STARS : displayStars)} + +
+
+ +
+ {cellPattern.map((rowArr) => + rowArr.map((cell) => { + const revealed = cell.col < step; + const isActive = cell.active && revealed; + + if (shouldReduceMotion) { + return ( +
+ ); + } + + return ( + + ); + }), + )} +
+ +
+
+ {AVATAR_GRADIENTS.map((grad, i) => ( +
+ ))} +
+ + Active contributors this week + +
+
+ ); +}; + +export default OpenSourceArt; diff --git a/apps/web/components/pages/HomePage/bento/PixelPerfectArt.tsx b/apps/web/components/pages/HomePage/bento/PixelPerfectArt.tsx new file mode 100644 index 0000000000..ea1d20ba57 --- /dev/null +++ b/apps/web/components/pages/HomePage/bento/PixelPerfectArt.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { motion, useReducedMotion } from "framer-motion"; +import { Cpu, Gauge, Maximize } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +const badges = [ + { icon: Maximize, label: "4K UHD" }, + { icon: Gauge, label: "60 FPS" }, + { icon: Cpu, label: "HW-Accelerated" }, +]; + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, "0"); + const s = (seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; +} + +export default function PixelPerfectArt({ className }: { className?: string }) { + const reduced = useReducedMotion(); + const [elapsed, setElapsed] = useState(23); + const intervalRef = useRef | null>(null); + + useEffect(() => { + if (reduced) return; + intervalRef.current = setInterval(() => { + setElapsed((prev) => (prev + 1) % 60); + }, 1000); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [reduced]); + + const dividerVariants = reduced + ? {} + : { + animate: { + left: ["25%", "75%", "25%"], + transition: { + duration: 6, + ease: "easeInOut", + repeat: Number.POSITIVE_INFINITY, + repeatType: "loop" as const, + }, + }, + }; + + return ( +
+
+
+ + + +
+ + + REC 4K • {formatTime(elapsed)} + +
+
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ +
+ + +
+
+ + +
+
+
+ +
+ + Pixelated + +
+
+ + 4K Sharp + +
+
+
+ +
+ {badges.map(({ icon: Icon, label }, i) => ( + + + + {label} + + + ))} +
+
+ ); +} diff --git a/apps/web/components/pages/HomePage/bento/StorageRoutingArt.tsx b/apps/web/components/pages/HomePage/bento/StorageRoutingArt.tsx new file mode 100644 index 0000000000..9a3b14a5c1 --- /dev/null +++ b/apps/web/components/pages/HomePage/bento/StorageRoutingArt.tsx @@ -0,0 +1,280 @@ +"use client"; + +import clsx from "clsx"; +import { motion, useReducedMotion } from "framer-motion"; +import { Cloud, Database, HardDrive } from "lucide-react"; +import { useId } from "react"; + +interface ArtProps { + className?: string; +} + +interface Destination { + id: string; + label: string; + icon: React.ReactNode; + cy: number; + primary?: boolean; +} + +interface Packet { + id: number; + delay: number; +} + +const DESTINATIONS: Destination[] = [ + { + id: "cloud", + label: "Cap Cloud", + icon: , + cy: 60, + }, + { + id: "s3", + label: "Your S3", + icon: , + cy: 140, + primary: true, + }, + { + id: "disk", + label: "Local Disk", + icon: , + cy: 220, + }, +]; + +const PACKETS: Packet[] = [ + { id: 0, delay: 0 }, + { id: 1, delay: 1.1 }, + { id: 2, delay: 2.2 }, +]; + +const SOURCE_X = 90; +const SOURCE_CY = 140; +const BRANCH_X = 220; +const DEST_X = 380; +const SVG_W = 480; +const SVG_H = 280; + +function buildPath(destCy: number): string { + const mx = (SOURCE_X + BRANCH_X) / 2; + const bx = (BRANCH_X + DEST_X) / 2; + return `M ${SOURCE_X} ${SOURCE_CY} C ${mx} ${SOURCE_CY}, ${mx} ${SOURCE_CY}, ${BRANCH_X} ${SOURCE_CY} C ${bx} ${SOURCE_CY}, ${bx} ${destCy}, ${DEST_X} ${destCy}`; +} + +function AnimatedPacket({ + destCy, + delay, + primary, + reduced, +}: { + destCy: number; + delay: number; + primary: boolean; + reduced: boolean; +}) { + const path = buildPath(destCy); + + if (reduced) { + return ( + + ); + } + + return ( + + ); +} + +function PathLine({ + destCy, + primary, + delay, +}: { + destCy: number; + primary: boolean; + delay: number; +}) { + const d = buildPath(destCy); + return ( + + ); +} + +const StorageRoutingArt = ({ className }: ArtProps) => { + const reduced = useReducedMotion() ?? false; + const uid = useId(); + const gridId = `grid-dots-${uid}`; + + return ( +
+
+
+ + + recording.mp4 + +
+
+ + + {DESTINATIONS.map((dest, i) => ( + +
+ {dest.icon} + + {dest.label} + +
+
+ ))} + + + +
+ ); +}; + +export default StorageRoutingArt; diff --git a/apps/web/components/pages/HomePage/index.tsx b/apps/web/components/pages/HomePage/index.tsx index b5f8ef72fd..636b6e3dbb 100644 --- a/apps/web/components/pages/HomePage/index.tsx +++ b/apps/web/components/pages/HomePage/index.tsx @@ -4,8 +4,8 @@ import type React from "react"; import { ReadyToGetStarted } from "@/components/ReadyToGetStarted"; import { TextReveal } from "@/components/ui/TextReveal"; import { homepageCopy } from "../../../data/homepage-copy"; +import Bento from "./Bento"; import Faq from "./Faq"; -import Features from "./Features"; import Header from "./Header"; import { HomePageSchema } from "./HomePageSchema"; import InstantModeDetail from "./InstantModeDetail"; @@ -31,7 +31,7 @@ export const HomePage: React.FC = ({ - + diff --git a/apps/web/data/homepage-copy.ts b/apps/web/data/homepage-copy.ts index 78ef449179..7ab5d13ac5 100644 --- a/apps/web/data/homepage-copy.ts +++ b/apps/web/data/homepage-copy.ts @@ -37,6 +37,21 @@ export interface FeaturesCopy { }[]; } +export interface BentoCopy { + eyebrow: string; + title: string; + subtitle: string; + cards: { + key: string; + title: string; + description: string; + }[]; + cta: { + label: string; + href: string; + }; +} + export interface TestimonialsCopy { title: string; subtitle: string; @@ -101,6 +116,7 @@ export interface HomePageCopy { textReveal: string; recordingModes: RecordingModesCopy; features: FeaturesCopy; + bento: BentoCopy; testimonials: TestimonialsCopy; pricing: PricingCopy; faq: FaqCopy; @@ -110,14 +126,14 @@ export interface HomePageCopy { export const homepageCopy: HomePageCopy = { header: { announcement: { - text: "🚨 Early Adopter Pricing Ends Soon - Lock In Your Discount", + text: "Early Adopter Pricing Ends Soon — Lock In Your Discount", href: "/pricing", }, variants: { default: { - title: "Beautiful, shareable screen recordings", + title: "Beautiful, Shareable Screen Recordings", description: - "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share securely in seconds with custom S3 bucket support. Connect your own domain.", + "Cap is the open-source alternative to Loom — native, fast, and yours to control. Record locally with full editing power, or share instantly while you record. Bring your own S3 bucket, your own domain, your own rules.", }, }, cta: { @@ -127,16 +143,16 @@ export const homepageCopy: HomePageCopy = { seeOtherOptionsText: "More download options", }, }, - textReveal: "Record. Edit. Share.", + textReveal: "Record. Edit. Share. — On Your Terms.", recordingModes: { - title: "Share instantly, or record and edit locally", + title: "Three Modes, Zero Compromise", subtitle: - "Instant Mode bypasses rendering with real-time uploading whilst you are recording. Studio Mode prioritizes quality with local recording and full editing capabilities.", + "Instant Mode uploads as you record, so a shareable link is ready the moment you stop. Studio Mode keeps everything local for pixel-perfect editing. Screenshot, when a single frame is enough.", modes: [ { name: "Instant Mode", description: - "Hit record, stop, share link. Your video is live in seconds with automatically generated captions, a title, summary, chapters, and more. Perfect for quick feedback, bug reports, or when you just need to show something fast.", + "Hit record, stop, share link. Your video is live in seconds with auto-generated captions, a title, summary, chapters, and more. Perfect for quick feedback, bug reports, or when you just need to show something fast.", }, { name: "Studio Mode", @@ -146,63 +162,111 @@ export const homepageCopy: HomePageCopy = { ], }, features: { - title: "Built for how you actually work", + title: "Built For How You Actually Work", subtitle: "We obsessed over the details so you don't have to. Every feature is designed to save you time and make you look good.", features: [ { - title: "Your storage, your rules", + title: "Your Storage, Your Rules", description: - "Connect your own S3 bucket, use our cloud, or keep everything local. Unlike other tools, you're never locked into our infrastructure. Perfect for teams with compliance requirements or those who value data sovereignty.", + "Connect your own S3 bucket, use Cap Cloud, or keep everything local. You're never locked into our infrastructure — perfect for teams with compliance requirements or anyone who values data sovereignty.", }, { - title: "Privacy by default, sharing by choice", + title: "Privacy by Default, Sharing by Choice", description: - "Instant sharing when you need it, local recording when you want it. Share publicly or privately. Password protect sensitive recordings or keep them local only.", + "Instant sharing when you need it, local recording when you want it. Share publicly or privately, password-protect sensitive recordings, or keep them local only.", }, { - title: "Async collaboration that actually works", + title: "Async Collaboration That Actually Works", description: 'Comments, reactions, and transcripts keep conversations moving without another meeting. See who watched, get notified on feedback, and turn recordings into actionable next steps. Replace those "quick sync" calls for good.', }, { - title: "Cross-platform for your entire team", + title: "Cross-Platform For Your Entire Team", description: - "Native apps for macOS and Windows that feel at home on each platform. No janky Electron apps or browser extensions. Just fast, reliable recording that works with your existing tools and workflow.", + "Native apps for macOS and Windows that feel at home on each platform. No janky Electron apps or browser extensions — just fast, reliable recording that works with your existing tools and workflow.", }, { - title: "Quality that makes you look professional", + title: "Quality That Makes You Look Professional", description: "4K recording, 60fps capture, and intelligent compression that keeps file sizes reasonable.", }, { - title: "Truly open source", + title: "Truly Open Source", description: "See exactly how Cap works, contribute features you need, or self-host for complete control. Join a community of builders who believe great tools should be transparent, extensible, and respect their users.", }, { - title: "Speed up your workflow with Cap AI", + title: "Speed Up Your Workflow With Cap AI", description: "Auto-generated titles, summaries, clickable chapters, and transcriptions for every recording. AI features that actually save time instead of creating more work.", }, { - title: "Import your Loom videos", + title: "Import Your Loom Videos", description: - "Switching from Loom? Import your existing Loom recordings directly into Cap with our built-in video importer. Keep all your content in one place without starting from scratch.", + "Switching from Loom? Import your existing recordings directly into Cap with our built-in importer. Keep all your content in one place without starting from scratch.", }, ], }, + bento: { + eyebrow: "Why Cap", + title: "Built To Be Yours", + subtitle: + "Every feature respects how you actually work — your storage, your platform, your workflow. No vendor lock-in, no compromises.", + cards: [ + { + key: "storage", + title: "Bring Your Own Storage", + description: + "Plug in your own S3 bucket, route to Cap Cloud, or keep recordings entirely local. Your videos, your bucket, your bill — no vendor lock-in, ever.", + }, + { + key: "ai", + title: "Cap AI Does The Busywork", + description: + "Every recording gets an AI-generated title, summary, clickable chapters, and a fully searchable transcript — so the work after the recording is already done.", + }, + { + key: "async", + title: "Async Conversations That Move", + description: + "Threaded comments, emoji reactions, and viewer analytics turn one-way videos into two-way conversations. Replace the standing meeting for good.", + }, + { + key: "native", + title: "Native, Not An Electron Tab", + description: + "Built on Tauri and Rust for genuinely native performance on macOS and Windows. No bloated browser, no battery hit — just a fast, lightweight recorder.", + }, + { + key: "oss", + title: "Open Source, End To End", + description: + "Inspect every line, contribute the feature you've been waiting for, or self-host the entire stack. Fair, transparent, and yours to fork.", + }, + { + key: "pixel", + title: "Pixel-Perfect Capture", + description: + "Record up to 4K at 60fps with hardware-accelerated encoding. Crisp text, smooth motion, sane file sizes — the quality your work deserves.", + }, + ], + cta: { + label: "Explore Every Feature", + href: "/features", + }, + }, testimonials: { - title: "Loved by builders, trusted by teams", + title: "Loved By Builders, Trusted By Teams", subtitle: "Join thousands who've made Cap their daily driver for visual communication.", - cta: "Read more testimonials", + cta: "Read More Testimonials", }, pricing: { - title: "Simple, honest pricing", + title: "Simple, Honest Pricing", subtitle: "Start free, upgrade when you need more. Early adopter pricing locked in forever.", - lovedBy: "Trusted by 10,000+ users", + lovedBy: "Trusted by 30,000+ users", commercial: { title: "Desktop License", description: @@ -242,7 +306,7 @@ export const homepageCopy: HomePageCopy = { "Custom S3 bucket support", "Priority support & early features", ], - cta: "Get started", + cta: "Get Started", pricing: { annual: 8.16, monthly: 12, @@ -255,7 +319,7 @@ export const homepageCopy: HomePageCopy = { }, }, faq: { - title: "Questions? We've got answers.", + title: "Questions? We've Got Answers.", items: [ { question: "What is the difference between Cap Pro and Desktop License?", @@ -295,7 +359,7 @@ export const homepageCopy: HomePageCopy = { { question: "Which platforms do you support?", answer: - "Native desktop apps for macOS (Apple Silicon & Intel) and Windows. View your shareable linkes from anywhere.", + "Native desktop apps for macOS (Apple Silicon & Intel) and Windows. View your shareable links from anywhere.", }, { question: "Can I use Cap for commercial purposes?", @@ -305,7 +369,7 @@ export const homepageCopy: HomePageCopy = { { question: "Is my data secure?", answer: - "Security is core to Cap. As an open source project, our code is fully auditable and transparent - you can see exactly how your data is handled. End-to-end encryption for cloud storage, option to use your own infrastructure, and community-driven security reviews keep your content safe.", + "Security is core to Cap. As an open source project, our code is fully auditable and transparent — you can see exactly how your data is handled. End-to-end encryption for cloud storage, option to use your own infrastructure, and community-driven security reviews keep your content safe.", }, { question: "What about GDPR/HIPAA compliance?", @@ -315,10 +379,10 @@ export const homepageCopy: HomePageCopy = { ], }, readyToGetStarted: { - title: "Ready to upgrade how you communicate?", + title: "Ready To Upgrade How You Communicate?", buttons: { primary: "Upgrade to Cap Pro", - secondary: "Download for free", + secondary: "Download For Free", }, }, }; diff --git a/apps/web/lib/google-drive-storage-quota.ts b/apps/web/lib/google-drive-storage-quota.ts new file mode 100644 index 0000000000..909337ff8b --- /dev/null +++ b/apps/web/lib/google-drive-storage-quota.ts @@ -0,0 +1,153 @@ +import { db } from "@cap/database"; +import { decrypt } from "@cap/database/crypto"; +import { storageIntegrations } from "@cap/database/schema"; +import { + type GoogleDriveIntegrationConfig, + type GoogleDriveStorageQuota, + type GoogleDriveStorageQuotaCache, + getGoogleDriveStorageQuota, +} from "@cap/web-backend"; +import { Storage } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; +import { runPromise } from "@/lib/server"; + +const googleDriveProvider = "googleDrive"; +const storageQuotaCacheTtlMs = 30 * 60 * 1000; +const storageQuotaRefreshFloorMs = 60 * 1000; + +type StorageIntegration = typeof storageIntegrations.$inferSelect; + +export type GoogleDriveStorageQuotaSnapshot = { + limit: string | null; + usage: string | null; + usageInDrive: string | null; + usageInDriveTrash: string | null; + remaining: string | null; + fetchedAt: string; + stale: boolean; +}; + +const parseConfig = async (encryptedConfig: string) => + JSON.parse(await decrypt(encryptedConfig)) as GoogleDriveIntegrationConfig; + +const nullableString = (value?: string | null) => value ?? null; + +const remainingBytes = ( + limit?: string | null, + usage?: string | null, +): string | null => { + if (!limit || !usage) return null; + + try { + const remaining = BigInt(limit) - BigInt(usage); + return (remaining > 0n ? remaining : 0n).toString(); + } catch { + return null; + } +}; + +const toCache = ( + quota: GoogleDriveStorageQuota, +): GoogleDriveStorageQuotaCache => ({ + limit: nullableString(quota.limit), + usage: nullableString(quota.usage), + usageInDrive: nullableString(quota.usageInDrive), + usageInDriveTrash: nullableString(quota.usageInDriveTrash), + fetchedAt: new Date().toISOString(), +}); + +const toSnapshot = ( + quota: GoogleDriveStorageQuotaCache, + stale: boolean, +): GoogleDriveStorageQuotaSnapshot => { + const limit = nullableString(quota.limit); + const usage = nullableString(quota.usage); + + return { + limit, + usage, + usageInDrive: nullableString(quota.usageInDrive), + usageInDriveTrash: nullableString(quota.usageInDriveTrash), + remaining: remainingBytes(limit, usage), + fetchedAt: quota.fetchedAt, + stale, + }; +}; + +const isFresh = (quota: GoogleDriveStorageQuotaCache, ttlMs: number) => { + const fetchedAt = Date.parse(quota.fetchedAt); + return Number.isFinite(fetchedAt) && Date.now() - fetchedAt < ttlMs; +}; + +const saveQuotaCache = async ( + integrationId: Storage.StorageIntegrationId, + encryptedConfig: string, + storageQuotaCache: GoogleDriveStorageQuotaCache, +) => { + await db() + .update(storageIntegrations) + .set({ googleDriveStorageQuotaCache: storageQuotaCache }) + .where( + and( + eq(storageIntegrations.id, integrationId), + eq(storageIntegrations.provider, googleDriveProvider), + eq(storageIntegrations.status, "active"), + eq(storageIntegrations.encryptedConfig, encryptedConfig), + ), + ); +}; + +export const getCachedGoogleDriveStorageQuota = async ( + integration: StorageIntegration, + options?: { forceRefresh?: boolean }, +): Promise => { + if ( + integration.provider !== googleDriveProvider || + integration.status !== "active" + ) { + return null; + } + + const config = await parseConfig(integration.encryptedConfig); + const cached = integration.googleDriveStorageQuotaCache; + const ttlMs = options?.forceRefresh + ? storageQuotaRefreshFloorMs + : storageQuotaCacheTtlMs; + + if (cached && isFresh(cached, ttlMs)) return toSnapshot(cached, false); + + try { + const quota = await getGoogleDriveStorageQuota(config).pipe(runPromise); + const storageQuotaCache = toCache(quota); + await saveQuotaCache( + integration.id, + integration.encryptedConfig, + storageQuotaCache, + ); + return toSnapshot(storageQuotaCache, false); + } catch (error) { + console.error("Failed to refresh Google Drive storage quota:", error); + return cached ? toSnapshot(cached, true) : null; + } +}; + +export const invalidateGoogleDriveStorageQuotaCache = async ( + integrationId: string | null | undefined, +) => { + if (!integrationId) return; + const storageIntegrationId = Storage.StorageIntegrationId.make(integrationId); + + try { + await db() + .update(storageIntegrations) + .set({ googleDriveStorageQuotaCache: null }) + .where( + and( + eq(storageIntegrations.id, storageIntegrationId), + eq(storageIntegrations.provider, googleDriveProvider), + ), + ); + } catch (error) { + console.error("Failed to invalidate Google Drive storage quota:", error); + } +}; diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index 573cc1ffd7..8d744a3976 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -13,6 +13,7 @@ import { S3Buckets, Spaces, SpacesPolicy, + Storage, Tinybird, Users, Videos, @@ -111,6 +112,7 @@ const WorkflowRpcLive = Layer.unwrapScoped( export const Dependencies = Layer.mergeAll( S3Buckets.Default, + Storage.Default, Videos.Default, VideosPolicy.Default, VideosRepo.Default, diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index 0b07f04ab8..606ef72f74 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -1,10 +1,5 @@ import { db } from "@cap/database"; -import { - organizations, - s3Buckets, - videos, - videoUploads, -} from "@cap/database/schema"; +import { organizations, videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; @@ -39,12 +34,10 @@ export async function transcribeVideo( const query = await db() .select({ video: videos, - bucket: s3Buckets, settings: videos.settings, orgSettings: organizations.settings, }) .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .leftJoin(organizations, eq(videos.orgId, organizations.id)) .where(eq(videos.id, videoId)); diff --git a/apps/web/lib/video-storage.ts b/apps/web/lib/video-storage.ts new file mode 100644 index 0000000000..f37409c6dc --- /dev/null +++ b/apps/web/lib/video-storage.ts @@ -0,0 +1,18 @@ +import type { videos } from "@cap/database/schema"; +import { Video } from "@cap/web-domain"; +import { Option } from "effect"; + +type DbVideo = typeof videos.$inferSelect; + +export const decodeStorageVideo = (video: DbVideo) => + Video.Video.make({ + ...video, + metadata: Option.fromNullable(video.metadata), + bucketId: Option.fromNullable(video.bucket), + storageIntegrationId: Option.fromNullable(video.storageIntegrationId), + folderId: Option.fromNullable(video.folderId), + transcriptionStatus: Option.fromNullable(video.transcriptionStatus), + width: Option.fromNullable(video.width), + height: Option.fromNullable(video.height), + duration: Option.fromNullable(video.duration), + }); diff --git a/apps/web/public/.well-known/workflow/v1/manifest.json b/apps/web/public/.well-known/workflow/v1/manifest.json index 9595691771..32f24e7964 100644 --- a/apps/web/public/.well-known/workflow/v1/manifest.json +++ b/apps/web/public/.well-known/workflow/v1/manifest.json @@ -1,36 +1,6 @@ { "version": "1.0.0", "steps": { - "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/stdlib.js": { - "fetch": { - "stepId": "step//workflow@4.2.0-beta.73//fetch" - } - }, - "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/internal/builtins.js": { - "__builtin_response_array_buffer": { - "stepId": "__builtin_response_array_buffer" - }, - "__builtin_response_json": { - "stepId": "__builtin_response_json" - }, - "__builtin_response_text": { - "stepId": "__builtin_response_text" - } - }, - "workflows/import-loom-video.ts": { - "downloadLoomToS3": { - "stepId": "step//./workflows/import-loom-video//downloadLoomToS3" - }, - "processVideoOnMediaServer": { - "stepId": "step//./workflows/import-loom-video//processVideoOnMediaServer" - }, - "saveMetadataAndComplete": { - "stepId": "step//./workflows/import-loom-video//saveMetadataAndComplete" - }, - "setProcessingError": { - "stepId": "step//./workflows/import-loom-video//setProcessingError" - } - }, "workflows/process-video.ts": { "cleanupRawUpload": { "stepId": "step//./workflows/process-video//cleanupRawUpload" @@ -48,21 +18,23 @@ "stepId": "step//./workflows/process-video//validateProcessingRequest" } }, - "workflows/generate-ai.ts": { - "fetchTranscript": { - "stepId": "step//./workflows/generate-ai//fetchTranscript" - }, - "generateWithAi": { - "stepId": "step//./workflows/generate-ai//generateWithAi" + "workflows/import-loom-video.ts": { + "downloadLoomToS3": { + "stepId": "step//./workflows/import-loom-video//downloadLoomToS3" }, - "markSkipped": { - "stepId": "step//./workflows/generate-ai//markSkipped" + "processVideoOnMediaServer": { + "stepId": "step//./workflows/import-loom-video//processVideoOnMediaServer" }, - "saveResults": { - "stepId": "step//./workflows/generate-ai//saveResults" + "saveMetadataAndComplete": { + "stepId": "step//./workflows/import-loom-video//saveMetadataAndComplete" }, - "validateAndSetProcessing": { - "stepId": "step//./workflows/generate-ai//validateAndSetProcessing" + "setProcessingError": { + "stepId": "step//./workflows/import-loom-video//setProcessingError" + } + }, + "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/stdlib.js": { + "fetch": { + "stepId": "step//workflow@4.2.0-beta.73//fetch" } }, "workflows/transcribe.ts": { @@ -96,19 +68,47 @@ "validateVideo": { "stepId": "step//./workflows/transcribe//validateVideo" } + }, + "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/internal/builtins.js": { + "__builtin_response_array_buffer": { + "stepId": "__builtin_response_array_buffer" + }, + "__builtin_response_json": { + "stepId": "__builtin_response_json" + }, + "__builtin_response_text": { + "stepId": "__builtin_response_text" + } + }, + "workflows/generate-ai.ts": { + "fetchTranscript": { + "stepId": "step//./workflows/generate-ai//fetchTranscript" + }, + "generateWithAi": { + "stepId": "step//./workflows/generate-ai//generateWithAi" + }, + "markSkipped": { + "stepId": "step//./workflows/generate-ai//markSkipped" + }, + "saveResults": { + "stepId": "step//./workflows/generate-ai//saveResults" + }, + "validateAndSetProcessing": { + "stepId": "step//./workflows/generate-ai//validateAndSetProcessing" + } } }, "workflows": { - "workflows/import-loom-video.ts": { - "importLoomVideoWorkflow": { - "workflowId": "workflow//./workflows/import-loom-video//importLoomVideoWorkflow", + "workflows/process-video.ts": { + "processVideoWorkflow": { + "workflowId": "workflow//./workflows/process-video//processVideoWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: importLoomVideoWorkflow", + "label": "Start: processVideoWorkflow", "nodeKind": "workflow_start" } }, @@ -132,16 +132,16 @@ } } }, - "workflows/process-video.ts": { - "processVideoWorkflow": { - "workflowId": "workflow//./workflows/process-video//processVideoWorkflow", + "workflows/import-loom-video.ts": { + "importLoomVideoWorkflow": { + "workflowId": "workflow//./workflows/import-loom-video//importLoomVideoWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: processVideoWorkflow", + "label": "Start: importLoomVideoWorkflow", "nodeKind": "workflow_start" } }, @@ -165,16 +165,16 @@ } } }, - "workflows/generate-ai.ts": { - "generateAiWorkflow": { - "workflowId": "workflow//./workflows/generate-ai//generateAiWorkflow", + "workflows/transcribe.ts": { + "transcribeVideoWorkflow": { + "workflowId": "workflow//./workflows/transcribe//transcribeVideoWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: generateAiWorkflow", + "label": "Start: transcribeVideoWorkflow", "nodeKind": "workflow_start" } }, @@ -198,16 +198,16 @@ } } }, - "workflows/transcribe.ts": { - "transcribeVideoWorkflow": { - "workflowId": "workflow//./workflows/transcribe//transcribeVideoWorkflow", + "workflows/generate-ai.ts": { + "generateAiWorkflow": { + "workflowId": "workflow//./workflows/generate-ai//generateAiWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: transcribeVideoWorkflow", + "label": "Start: generateAiWorkflow", "nodeKind": "workflow_start" } }, diff --git a/apps/web/utils/desktop.ts b/apps/web/utils/desktop.ts index 431d760dfb..bb4260140f 100644 --- a/apps/web/utils/desktop.ts +++ b/apps/web/utils/desktop.ts @@ -10,6 +10,17 @@ export function isFromDesktopSemver( } export const UPLOAD_PROGRESS_VERSION = [0, 3, 68] as const; +export const GOOGLE_DRIVE_UPLOAD_FEATURE = "googleDriveUpload"; + +export function hasDesktopFeature(request: HonoRequest, feature: string) { + return ( + request + .header("X-Cap-Desktop-Features") + ?.split(",") + .map((value) => value.trim()) + .includes(feature) ?? false + ); +} export function isAtLeastSemver( versionString: string, diff --git a/apps/web/utils/upload-target.ts b/apps/web/utils/upload-target.ts new file mode 100644 index 0000000000..47ae42c4cf --- /dev/null +++ b/apps/web/utils/upload-target.ts @@ -0,0 +1,88 @@ +import type { Storage } from "@cap/web-domain"; + +type UploadTarget = + | Storage.UploadTarget + | { + url: string; + fields: Record; + }; + +type UploadProgress = { + loaded: number; + total: number; +}; + +const isPostTarget = ( + target: UploadTarget, +): target is { url: string; fields: Record } => + !("type" in target) || target.type === "s3Post"; + +const isDriveResumableTarget = ( + target: UploadTarget, +): target is Extract => + "type" in target && target.type === "driveResumable"; + +export function uploadWithTarget({ + target, + body, + fileName, + onProgress, +}: { + target: UploadTarget; + body: Blob; + fileName?: string; + onProgress?: (progress: UploadProgress) => void; +}) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + if (isPostTarget(target)) { + const formData = new FormData(); + Object.entries(target.fields).forEach(([key, value]) => { + formData.append(key, value); + }); + formData.append("file", body, fileName); + xhr.open("POST", target.url); + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + onProgress?.({ loaded: event.loaded, total: event.total }); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }; + xhr.onerror = () => reject(new Error("Upload failed")); + xhr.send(formData); + return; + } + + xhr.open("PUT", target.url); + Object.entries(target.headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value); + }); + if (isDriveResumableTarget(target) && body.size > 0) { + xhr.setRequestHeader( + "Content-Range", + `bytes 0-${body.size - 1}/${body.size}`, + ); + } + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + onProgress?.({ loaded: event.loaded, total: event.total }); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }; + xhr.onerror = () => reject(new Error("Upload failed")); + xhr.send(body); + }); +} diff --git a/apps/web/workflows/generate-ai.ts b/apps/web/workflows/generate-ai.ts index 44813794c8..73dc01fb54 100644 --- a/apps/web/workflows/generate-ai.ts +++ b/apps/web/workflows/generate-ai.ts @@ -1,14 +1,15 @@ import { db } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; +import { videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; -import type { S3Bucket, Video } from "@cap/web-domain"; +import { Storage } from "@cap/web-backend"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { FatalError } from "workflow"; import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; interface GenerateAiWorkflowPayload { videoId: string; @@ -17,7 +18,6 @@ interface GenerateAiWorkflowPayload { interface VideoData { video: typeof videos.$inferSelect; - bucketId: S3Bucket.S3BucketId | null; metadata: VideoMetadata; } @@ -46,7 +46,7 @@ export async function generateAiWorkflow(payload: GenerateAiWorkflowPayload) { const videoData = await validateAndSetProcessing(videoId); - const transcript = await fetchTranscript(videoId, userId, videoData.bucketId); + const transcript = await fetchTranscript(videoId, userId, videoData.video); if (!transcript) { await markSkipped(videoId, videoData.metadata); @@ -72,16 +72,15 @@ async function validateAndSetProcessing(videoId: string): Promise { } const query = await db() - .select({ video: videos, bucket: s3Buckets }) + .select({ video: videos }) .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .where(eq(videos.id, videoId as Video.VideoId)); if (query.length === 0 || !query[0]?.video) { throw new FatalError("Video does not exist"); } - const { video, bucket } = query[0]; + const { video } = query[0]; const metadata = (video.metadata as VideoMetadata) || {}; if (video.transcriptionStatus !== "COMPLETE") { @@ -104,7 +103,6 @@ async function validateAndSetProcessing(videoId: string): Promise { return { video, - bucketId: (bucket?.id ?? null) as S3Bucket.S3BucketId | null, metadata, }; } @@ -112,13 +110,13 @@ async function validateAndSetProcessing(videoId: string): Promise { async function fetchTranscript( videoId: string, userId: string, - bucketId: S3Bucket.S3BucketId | null, + video: typeof videos.$inferSelect, ): Promise { "use step"; const vtt = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess( - Option.fromNullable(bucketId), + const [bucket] = yield* Storage.getAccessForVideo( + decodeStorageVideo(video), ); return yield* bucket.getObject(`${userId}/${videoId}/transcription.vtt`); }).pipe(runPromise); diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts index 86418d5289..8332f95a10 100644 --- a/apps/web/workflows/import-loom-video.ts +++ b/apps/web/workflows/import-loom-video.ts @@ -2,10 +2,10 @@ import { randomUUID } from "node:crypto"; import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; -import { S3Bucket, type Video } from "@cap/web-domain"; +import { Storage } from "@cap/web-backend"; +import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; +import { Effect } from "effect"; import { FatalError } from "workflow"; import { runPromise } from "@/lib/server"; @@ -25,6 +25,10 @@ function isStreamingUrl(url: string): boolean { return path.endsWith(".m3u8") || path.endsWith(".mpd"); } +function isGoogleDriveResumableUrl(url: string): boolean { + return url.includes("googleapis.com/upload/drive/"); +} + async function fetchLoomCdnUrl( videoId: string, endpoint: string, @@ -172,7 +176,7 @@ async function downloadLoomToS3( ): Promise { "use step"; - const { videoId, loomVideoId, rawFileKey, bucketId } = payload; + const { videoId, loomVideoId, rawFileKey } = payload; await db() .update(videoUploads) @@ -204,12 +208,25 @@ async function downloadLoomToS3( }; } - const bucketIdOption = Option.fromNullable(bucketId).pipe( - Option.map((id) => S3Bucket.S3BucketId.make(id)), - ); - const presignedPutUrl = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(bucketIdOption); + const [video] = yield* Effect.promise(() => + db() + .select() + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))), + ); + if (!video) { + return yield* Effect.fail(new FatalError("Video does not exist")); + } + const videoDomain = Video.Video.decodeSync({ + ...video, + bucketId: video.bucket, + storageIntegrationId: video.storageIntegrationId, + createdAt: video.createdAt.toISOString(), + updatedAt: video.updatedAt.toISOString(), + metadata: video.metadata, + }); + const [bucket] = yield* Storage.getAccessForVideo(videoDomain); return yield* bucket.getInternalPresignedPutUrl(rawFileKey, { ContentType: "video/mp4", }); @@ -223,13 +240,19 @@ async function downloadLoomToS3( ); } + const uploadHeaders: Record = { + "Content-Type": "video/mp4", + "Content-Length": videoBuffer.length.toString(), + }; + if (isGoogleDriveResumableUrl(presignedPutUrl) && videoBuffer.length > 0) { + uploadHeaders["Content-Range"] = + `bytes 0-${videoBuffer.length - 1}/${videoBuffer.length}`; + } + const uploadResponse = await fetch(presignedPutUrl, { method: "PUT", body: new Uint8Array(videoBuffer), - headers: { - "Content-Type": "video/mp4", - "Content-Length": videoBuffer.length.toString(), - }, + headers: uploadHeaders, }); if (!uploadResponse.ok) { @@ -330,7 +353,7 @@ async function processVideoOnMediaServer( ): Promise { "use step"; - const { videoId, userId, rawFileKey, bucketId } = payload; + const { videoId, userId, rawFileKey } = payload; const mediaServerUrl = serverEnv().MEDIA_SERVER_URL; if (!mediaServerUrl) { @@ -340,13 +363,26 @@ async function processVideoOnMediaServer( const webhookBaseUrl = serverEnv().MEDIA_SERVER_WEBHOOK_URL || serverEnv().WEB_URL; - const bucketIdOption = Option.fromNullable(bucketId).pipe( - Option.map((id) => S3Bucket.S3BucketId.make(id)), - ); - const { rawVideoUrl, outputPresignedUrl, thumbnailPresignedUrl } = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(bucketIdOption); + const [video] = yield* Effect.promise(() => + db() + .select() + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))), + ); + if (!video) { + return yield* Effect.fail(new FatalError("Video does not exist")); + } + const videoDomain = Video.Video.decodeSync({ + ...video, + bucketId: video.bucket, + storageIntegrationId: video.storageIntegrationId, + createdAt: video.createdAt.toISOString(), + updatedAt: video.updatedAt.toISOString(), + metadata: video.metadata, + }); + const [bucket] = yield* Storage.getAccessForVideo(videoDomain); const outputKey = `${userId}/${videoId}/result.mp4`; const thumbnailKey = `${userId}/${videoId}/screenshot/screen-capture.jpg`; diff --git a/apps/web/workflows/process-video.ts b/apps/web/workflows/process-video.ts index 08f1526d8a..4701ab60fc 100644 --- a/apps/web/workflows/process-video.ts +++ b/apps/web/workflows/process-video.ts @@ -1,12 +1,12 @@ import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; -import type { S3Bucket, Video } from "@cap/web-domain"; +import { Storage } from "@cap/web-backend"; +import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Option } from "effect"; import { FatalError } from "workflow"; import { runPromise } from "@/lib/server"; +import { decodeStorageVideo } from "@/lib/video-storage"; interface ProcessVideoWorkflowPayload { videoId: string; @@ -48,7 +48,7 @@ export async function processVideoWorkflow( ); await saveMetadataAndComplete(videoId, result.metadata); - await cleanupRawUpload(rawFileKey, bucketId); + await cleanupRawUpload(videoId, rawFileKey); return { success: true, @@ -212,7 +212,7 @@ async function processVideoOnMediaServer( videoId: string, userId: string, rawFileKey: string, - bucketId: string | null, + _bucketId: string | null, ): Promise { "use step"; @@ -223,9 +223,19 @@ async function processVideoOnMediaServer( throw new FatalError("MEDIA_SERVER_URL is not configured"); } - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId as S3Bucket.S3BucketId | null), - ).pipe(runPromise); + const [video] = await db() + .select() + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))); + + if (!video) { + throw new FatalError("Video does not exist"); + } + + const videoDomain = decodeStorageVideo(video); + + const [bucket] = + await Storage.getAccessForVideo(videoDomain).pipe(runPromise); const rawVideoUrl = await bucket .getInternalSignedObjectUrl(rawFileKey) @@ -345,15 +355,23 @@ async function saveMetadataAndComplete( } async function cleanupRawUpload( + videoId: string, rawFileKey: string, - bucketId: string | null, ): Promise { "use step"; try { - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId as S3Bucket.S3BucketId | null), - ).pipe(runPromise); + const [video] = await db() + .select() + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))); + + if (!video) return; + + const videoDomain = decodeStorageVideo(video); + + const [bucket] = + await Storage.getAccessForVideo(videoDomain).pipe(runPromise); await bucket.deleteObject(rawFileKey).pipe(runPromise); } catch (error) { diff --git a/apps/web/workflows/transcribe.ts b/apps/web/workflows/transcribe.ts index 57d55072f9..a08ca3b229 100644 --- a/apps/web/workflows/transcribe.ts +++ b/apps/web/workflows/transcribe.ts @@ -2,7 +2,6 @@ import { promises as fs } from "node:fs"; import { db } from "@cap/database"; import { organizations, - s3Buckets, users, videos, videoUploads, @@ -10,11 +9,10 @@ import { import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; -import { S3Buckets } from "@cap/web-backend"; -import type { S3Bucket, Video } from "@cap/web-domain"; +import { Storage } from "@cap/web-backend"; +import type { Video } from "@cap/web-domain"; import { createClient } from "@deepgram/sdk"; import { eq } from "drizzle-orm"; -import { Option } from "effect"; import { FatalError } from "workflow"; import { ENHANCED_AUDIO_CONTENT_TYPE, @@ -31,6 +29,7 @@ import { } from "@/lib/media-client"; import { runPromise } from "@/lib/server"; import { type DeepgramResult, formatToWebVTT } from "@/lib/transcribe-utils"; +import { decodeStorageVideo } from "@/lib/video-storage"; interface TranscribeWorkflowPayload { videoId: string; @@ -40,7 +39,6 @@ interface TranscribeWorkflowPayload { interface VideoData { video: typeof videos.$inferSelect; - bucketId: S3Bucket.S3BucketId | null; transcriptionDisabled: boolean; isOwnerPro: boolean; } @@ -59,7 +57,7 @@ export async function transcribeVideoWorkflow( return { success: true, message: "Transcription disabled - skipped" }; } - const audioUrl = await extractAudio(videoId, userId, videoData.bucketId); + const audioUrl = await extractAudio(videoId, userId, videoData.video); if (!audioUrl) { await markNoAudio(videoId); @@ -69,27 +67,11 @@ export async function transcribeVideoWorkflow( }; } - // const enhancementConfigured = isAudioEnhancementConfigured(); - // const shouldEnhanceAudio = videoData.isOwnerPro && enhancementConfigured; + const [transcription] = await Promise.all([transcribeWithDeepgram(audioUrl)]); - // console.log( - // `[transcribe] Audio enhancement check: isOwnerPro=${videoData.isOwnerPro}, configured=${enhancementConfigured}, shouldEnhance=${shouldEnhanceAudio}`, - // ); + await saveTranscription(videoId, userId, videoData.video, transcription); - // if (shouldEnhanceAudio) { - // await markEnhancedAudioProcessing(videoId); - // } - - const [transcription] = await Promise.all([ - transcribeWithDeepgram(audioUrl), - // shouldEnhanceAudio - // ? enhanceAndSaveAudio(videoId, userId, audioUrl, videoData.bucketId) - // : Promise.resolve(), - ]); - - await saveTranscription(videoId, userId, videoData.bucketId, transcription); - - await cleanupTempAudio(videoId, userId, videoData.bucketId); + await cleanupTempAudio(videoId, userId, videoData.video); if (aiGenerationEnabled) { await queueAiGeneration(videoId, userId); @@ -108,13 +90,11 @@ async function validateVideo(videoId: string): Promise { const query = await db() .select({ video: videos, - bucket: s3Buckets, settings: videos.settings, orgSettings: organizations.settings, owner: users, }) .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .leftJoin(organizations, eq(videos.orgId, organizations.id)) .innerJoin(users, eq(videos.ownerId, users.id)) .where(eq(videos.id, videoId as Video.VideoId)); @@ -146,7 +126,6 @@ async function validateVideo(videoId: string): Promise { return { video: result.video, - bucketId: (result.bucket?.id ?? null) as S3Bucket.S3BucketId | null, transcriptionDisabled, isOwnerPro, }; @@ -173,15 +152,15 @@ async function markNoAudio(videoId: string): Promise { async function extractAudio( videoId: string, userId: string, - bucketId: S3Bucket.S3BucketId | null, + video: typeof videos.$inferSelect, ): Promise { "use step"; - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId), + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), ).pipe(runPromise); - const videoUrl = await resolveVideoSourceUrl(videoId, userId, bucketId); + const videoUrl = await resolveVideoSourceUrl(videoId, userId, video); const useMediaServer = isMediaServerConfigured(); console.log( @@ -257,10 +236,10 @@ async function extractAudio( async function resolveVideoSourceUrl( videoId: string, userId: string, - bucketId: S3Bucket.S3BucketId | null, + video: typeof videos.$inferSelect, ): Promise { - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId), + const [resolvedBucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), ).pipe(runPromise); const upload = await db() @@ -278,7 +257,9 @@ async function resolveVideoSourceUrl( ); for (const key of candidateKeys) { - const url = await bucket.getInternalSignedObjectUrl(key).pipe(runPromise); + const url = await resolvedBucket + .getInternalSignedObjectUrl(key) + .pipe(runPromise); const response = await fetch(url, { method: "GET", headers: { range: "bytes=0-0" }, @@ -328,13 +309,13 @@ async function transcribeWithDeepgram(audioUrl: string): Promise { async function saveTranscription( videoId: string, userId: string, - bucketId: S3Bucket.S3BucketId | null, + video: typeof videos.$inferSelect, transcription: string, ): Promise { "use step"; - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId), + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), ).pipe(runPromise); await bucket @@ -352,15 +333,15 @@ async function saveTranscription( async function cleanupTempAudio( videoId: string, userId: string, - bucketId: S3Bucket.S3BucketId | null, + video: typeof videos.$inferSelect, ): Promise { "use step"; const audioKey = `${userId}/${videoId}/audio-temp.mp3`; try { - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId), + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), ).pipe(runPromise); await bucket.deleteObject(audioKey).pipe(runPromise); @@ -406,7 +387,7 @@ async function _enhanceAndSaveAudio( videoId: string, userId: string, audioUrl: string, - bucketId: S3Bucket.S3BucketId | null, + video: typeof videos.$inferSelect, ): Promise { "use step"; @@ -418,8 +399,8 @@ async function _enhanceAndSaveAudio( `[transcribe] Audio enhanced, saving to S3 (${enhancedBuffer.length} bytes)`, ); - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId), + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), ).pipe(runPromise); const enhancedAudioKey = `${userId}/${videoId}/enhanced-audio.${ENHANCED_AUDIO_EXTENSION}`; @@ -430,12 +411,12 @@ async function _enhanceAndSaveAudio( }) .pipe(runPromise); - const [video] = await db() + const [videoRecord] = await db() .select({ metadata: videos.metadata }) .from(videos) .where(eq(videos.id, videoId as Video.VideoId)); - const currentMetadata = (video?.metadata as VideoMetadata) || {}; + const currentMetadata = (videoRecord?.metadata as VideoMetadata) || {}; await db() .update(videos) diff --git a/packages/database/migrations/0017_productive_betty_brant.sql b/packages/database/migrations/0017_productive_betty_brant.sql new file mode 100644 index 0000000000..f8f7af318d --- /dev/null +++ b/packages/database/migrations/0017_productive_betty_brant.sql @@ -0,0 +1,39 @@ +CREATE TABLE `storage_integrations` ( + `id` varchar(15) NOT NULL, + `ownerId` varchar(15) NOT NULL, + `provider` varchar(64) NOT NULL, + `displayName` varchar(255) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'active', + `active` boolean NOT NULL DEFAULT false, + `encryptedConfig` text NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `storage_integrations_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `storage_objects` ( + `id` varchar(15) NOT NULL, + `integrationId` varchar(15) NOT NULL, + `ownerId` varchar(15) NOT NULL, + `videoId` varchar(15), + `objectKey` text NOT NULL, + `objectKeyHash` varchar(64) NOT NULL, + `providerObjectId` varchar(255) NOT NULL, + `uploadSessionUrl` text, + `uploadStatus` varchar(32) NOT NULL DEFAULT 'pending', + `contentType` varchar(255), + `contentLength` bigint unsigned, + `metadata` json, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `storage_objects_id` PRIMARY KEY(`id`), + CONSTRAINT `integration_key_hash_idx` UNIQUE(`integrationId`,`objectKeyHash`) +); +--> statement-breakpoint +ALTER TABLE `videos` ADD `storageIntegrationId` varchar(15);--> statement-breakpoint +CREATE INDEX `owner_provider_idx` ON `storage_integrations` (`ownerId`,`provider`);--> statement-breakpoint +CREATE INDEX `owner_active_idx` ON `storage_integrations` (`ownerId`,`active`);--> statement-breakpoint +CREATE INDEX `integration_status_idx` ON `storage_objects` (`integrationId`,`uploadStatus`);--> statement-breakpoint +CREATE INDEX `video_id_idx` ON `storage_objects` (`videoId`);--> statement-breakpoint +CREATE INDEX `owner_id_idx` ON `storage_objects` (`ownerId`);--> statement-breakpoint +CREATE INDEX `storage_integration_id_idx` ON `videos` (`storageIntegrationId`); \ No newline at end of file diff --git a/packages/database/migrations/0018_loud_mongu.sql b/packages/database/migrations/0018_loud_mongu.sql new file mode 100644 index 0000000000..b03481e011 --- /dev/null +++ b/packages/database/migrations/0018_loud_mongu.sql @@ -0,0 +1,2 @@ +ALTER TABLE `storage_objects` ADD CONSTRAINT `storage_objects_integrationId_storage_integrations_id_fk` FOREIGN KEY (`integrationId`) REFERENCES `storage_integrations`(`id`) ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `videos` ADD CONSTRAINT `videos_storageIntegrationId_storage_integrations_id_fk` FOREIGN KEY (`storageIntegrationId`) REFERENCES `storage_integrations`(`id`) ON DELETE restrict ON UPDATE no action; \ No newline at end of file diff --git a/packages/database/migrations/0019_solid_paibok.sql b/packages/database/migrations/0019_solid_paibok.sql new file mode 100644 index 0000000000..1a1015f200 --- /dev/null +++ b/packages/database/migrations/0019_solid_paibok.sql @@ -0,0 +1,4 @@ +ALTER TABLE `storage_integrations` ADD `googleDriveAccessToken` text;--> statement-breakpoint +ALTER TABLE `storage_integrations` ADD `googleDriveAccessTokenExpiresAt` timestamp;--> statement-breakpoint +ALTER TABLE `storage_integrations` ADD `googleDriveTokenRefreshLeaseId` varchar(64);--> statement-breakpoint +ALTER TABLE `storage_integrations` ADD `googleDriveTokenRefreshLeaseExpiresAt` timestamp; \ No newline at end of file diff --git a/packages/database/migrations/0020_orange_talkback.sql b/packages/database/migrations/0020_orange_talkback.sql new file mode 100644 index 0000000000..b6f3328fa2 --- /dev/null +++ b/packages/database/migrations/0020_orange_talkback.sql @@ -0,0 +1 @@ +ALTER TABLE `storage_integrations` ADD `googleDriveStorageQuotaCache` json; \ No newline at end of file diff --git a/packages/database/migrations/meta/0017_snapshot.json b/packages/database/migrations/meta/0017_snapshot.json new file mode 100644 index 0000000000..3d7c777347 --- /dev/null +++ b/packages/database/migrations/meta/0017_snapshot.json @@ -0,0 +1,3054 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "327bc575-f9d8-4a5f-8e4e-e609ebbb12a3", + "prevId": "b1871e3d-74aa-4710-b163-c42bea25f59a", + "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": {} + }, + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "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'" + }, + "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 + } + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "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 + }, + "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')" + } + }, + "indexes": {}, + "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 + }, + "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'" + } + }, + "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 + } + }, + "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 + }, + "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 + }, + "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 + } + }, + "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": {}, + "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 + } + }, + "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_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": {}, + "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": {}, + "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/0018_snapshot.json b/packages/database/migrations/meta/0018_snapshot.json new file mode 100644 index 0000000000..86175ddd83 --- /dev/null +++ b/packages/database/migrations/meta/0018_snapshot.json @@ -0,0 +1,3074 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "c25cd0ad-2616-4467-a0a5-dd9747bf0f97", + "prevId": "327bc575-f9d8-4a5f-8e4e-e609ebbb12a3", + "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": {} + }, + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "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'" + }, + "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 + } + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "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 + }, + "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')" + } + }, + "indexes": {}, + "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 + }, + "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'" + } + }, + "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 + } + }, + "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 + }, + "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 + }, + "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 + } + }, + "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 + } + }, + "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_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": {}, + "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/0019_snapshot.json b/packages/database/migrations/meta/0019_snapshot.json new file mode 100644 index 0000000000..3ffb954c77 --- /dev/null +++ b/packages/database/migrations/meta/0019_snapshot.json @@ -0,0 +1,3102 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "79570045-2688-4a6b-afee-d06d704abfa2", + "prevId": "c25cd0ad-2616-4467-a0a5-dd9747bf0f97", + "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": {} + }, + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "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'" + }, + "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 + } + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "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 + }, + "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')" + } + }, + "indexes": {}, + "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 + }, + "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'" + } + }, + "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 + } + }, + "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 + }, + "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 + }, + "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 + } + }, + "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 + } + }, + "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_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": {}, + "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/0020_snapshot.json b/packages/database/migrations/meta/0020_snapshot.json new file mode 100644 index 0000000000..eb6625ac9f --- /dev/null +++ b/packages/database/migrations/meta/0020_snapshot.json @@ -0,0 +1,3109 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "6ab81fa9-13db-405f-94b3-19becbd18ec9", + "prevId": "79570045-2688-4a6b-afee-d06d704abfa2", + "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": {} + }, + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "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'" + }, + "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 + } + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "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 + }, + "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')" + } + }, + "indexes": {}, + "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 + }, + "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'" + } + }, + "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 + } + }, + "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 + }, + "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 + } + }, + "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 + } + }, + "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_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": {}, + "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 04a171cb7a..ac06e3d41f 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -120,6 +120,34 @@ "when": 1772641549840, "tag": "0016_bouncy_hellcat", "breakpoints": true + }, + { + "idx": 17, + "version": "5", + "when": 1778099532336, + "tag": "0017_productive_betty_brant", + "breakpoints": true + }, + { + "idx": 18, + "version": "5", + "when": 1778153157657, + "tag": "0018_loud_mongu", + "breakpoints": true + }, + { + "idx": 19, + "version": "5", + "when": 1778154209547, + "tag": "0019_solid_paibok", + "breakpoints": true + }, + { + "idx": 20, + "version": "5", + "when": 1778157016497, + "tag": "0020_orange_talkback", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 091131c343..9efd3f581f 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -5,6 +5,7 @@ import type { Organisation, S3Bucket, Space, + Storage, User, Video, } from "@cap/web-domain"; @@ -32,6 +33,14 @@ import { relations } from "drizzle-orm/relations"; import { nanoIdLength } from "./helpers.ts"; import type { VideoMetadata } from "./types/index.ts"; +type GoogleDriveStorageQuotaCache = { + limit?: string | null; + usage?: string | null; + usageInDrive?: string | null; + usageInDriveTrash?: string | null; + fetchedAt: string; +}; + const nanoId = customType<{ data: string; notNull: true }>({ dataType() { return `varchar(${nanoIdLength})`; @@ -297,6 +306,9 @@ export const videos = mysqlTable( orgId: nanoIdRequired("orgId").$type(), name: varchar("name", { length: 255 }).notNull().default("My Video"), bucket: nanoIdNullable("bucket").$type(), + storageIntegrationId: nanoIdNullable("storageIntegrationId") + .references(() => storageIntegrations.id, { onDelete: "restrict" }) + .$type(), // in seconds duration: float("duration"), width: int("width"), @@ -353,6 +365,7 @@ export const videos = mysqlTable( index("owner_id_idx").on(table.ownerId), index("is_public_idx").on(table.public), index("folder_id_idx").on(table.folderId), + index("storage_integration_id_idx").on(table.storageIntegrationId), index("org_owner_folder_idx").on( table.orgId, table.ownerId, @@ -563,6 +576,87 @@ export const s3Buckets = mysqlTable("s3_buckets", { provider: text("provider").notNull().default("aws"), }); +export const storageIntegrations = mysqlTable( + "storage_integrations", + { + id: nanoId("id") + .notNull() + .primaryKey() + .$type(), + ownerId: nanoId("ownerId").notNull().$type(), + provider: varchar("provider", { length: 64 }) + .notNull() + .$type(), + displayName: varchar("displayName", { length: 255 }).notNull(), + status: varchar("status", { length: 32 }) + .notNull() + .default("active") + .$type(), + active: boolean("active").notNull().default(false), + encryptedConfig: encryptedText("encryptedConfig").notNull(), + googleDriveAccessToken: encryptedTextNullable("googleDriveAccessToken"), + googleDriveAccessTokenExpiresAt: timestamp( + "googleDriveAccessTokenExpiresAt", + ), + googleDriveTokenRefreshLeaseId: varchar("googleDriveTokenRefreshLeaseId", { + length: 64, + }), + googleDriveTokenRefreshLeaseExpiresAt: timestamp( + "googleDriveTokenRefreshLeaseExpiresAt", + ), + googleDriveStorageQuotaCache: json( + "googleDriveStorageQuotaCache", + ).$type(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + ownerProviderIndex: index("owner_provider_idx").on( + table.ownerId, + table.provider, + ), + ownerActiveIndex: index("owner_active_idx").on(table.ownerId, table.active), + }), +); + +export const storageObjects = mysqlTable( + "storage_objects", + { + id: nanoId("id").notNull().primaryKey().$type(), + integrationId: nanoId("integrationId") + .notNull() + .references(() => storageIntegrations.id, { onDelete: "restrict" }) + .$type(), + ownerId: nanoId("ownerId").notNull().$type(), + videoId: nanoIdNullable("videoId").$type(), + objectKey: text("objectKey").notNull(), + objectKeyHash: varchar("objectKeyHash", { length: 64 }).notNull(), + providerObjectId: varchar("providerObjectId", { length: 255 }).notNull(), + uploadSessionUrl: encryptedTextNullable("uploadSessionUrl"), + uploadStatus: varchar("uploadStatus", { length: 32 }) + .notNull() + .default("pending") + .$type<"pending" | "complete" | "error">(), + contentType: varchar("contentType", { length: 255 }), + contentLength: bigint("contentLength", { mode: "number", unsigned: true }), + metadata: json("metadata").$type(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + integrationKeyHashIndex: uniqueIndex("integration_key_hash_idx").on( + table.integrationId, + table.objectKeyHash, + ), + integrationStatusIndex: index("integration_status_idx").on( + table.integrationId, + table.uploadStatus, + ), + videoIdIndex: index("video_id_idx").on(table.videoId), + ownerIdIndex: index("owner_id_idx").on(table.ownerId), + }), +); + export const notificationsRelations = relations(notifications, ({ one }) => ({ org: one(organizations, { fields: [notifications.orgId], @@ -628,6 +722,7 @@ export const usersRelations = relations(users, ({ many, one }) => ({ videos: many(videos), sharedVideos: many(sharedVideos), customBucket: one(s3Buckets), + storageIntegrations: many(storageIntegrations), spaces: many(spaces), spaceMembers: many(spaceMembers), messengerConversations: many(messengerConversations), @@ -648,6 +743,28 @@ export const s3BucketsRelations = relations(s3Buckets, ({ one }) => ({ }), })); +export const storageIntegrationsRelations = relations( + storageIntegrations, + ({ one, many }) => ({ + owner: one(users, { + fields: [storageIntegrations.ownerId], + references: [users.id], + }), + objects: many(storageObjects), + }), +); + +export const storageObjectsRelations = relations(storageObjects, ({ one }) => ({ + integration: one(storageIntegrations, { + fields: [storageObjects.integrationId], + references: [storageIntegrations.id], + }), + video: one(videos, { + fields: [storageObjects.videoId], + references: [videos.id], + }), +})); + export const organizationsRelations = relations( organizations, ({ one, many }) => ({ @@ -715,6 +832,10 @@ export const videosRelations = relations(videos, ({ one, many }) => ({ fields: [videos.folderId], references: [folders.id], }), + storageIntegration: one(storageIntegrations, { + fields: [videos.storageIntegrationId], + references: [storageIntegrations.id], + }), })); export const sharedVideosRelations = relations(sharedVideos, ({ one }) => ({ diff --git a/packages/web-api-contract/src/desktop.ts b/packages/web-api-contract/src/desktop.ts index bf75b6d06a..d0b86c78c3 100644 --- a/packages/web-api-contract/src/desktop.ts +++ b/packages/web-api-contract/src/desktop.ts @@ -47,6 +47,31 @@ export type OrganizationBrandingPatchBody = z.infer< typeof OrganizationBrandingPatchBody >; +export const DesktopStorageIntegrations = z.object({ + activeProvider: z.enum(["s3", "googleDrive"]), + googleDrive: z.object({ + id: z.string().nullable(), + connected: z.boolean(), + active: z.boolean(), + status: z.enum(["active", "disconnected", "error"]).nullable(), + displayName: z.string().nullable(), + storageQuota: z + .object({ + limit: z.string().nullable(), + usage: z.string().nullable(), + usageInDrive: z.string().nullable(), + usageInDriveTrash: z.string().nullable(), + remaining: z.string().nullable(), + fetchedAt: z.string(), + stale: z.boolean(), + }) + .nullable(), + }), +}); +export type DesktopStorageIntegrations = z.infer< + typeof DesktopStorageIntegrations +>; + const CHANGELOG = z.object({ metadata: z.object({ title: z.string(), @@ -158,6 +183,56 @@ const protectedContract = c.router( }), responses: { 200: z.object({ success: z.literal(true) }) }, }, + getStorageIntegrations: { + method: "GET", + path: "/desktop/storage/integrations", + query: z + .object({ + refreshStorageQuota: z.boolean().optional(), + }) + .optional(), + responses: { + 200: DesktopStorageIntegrations, + }, + }, + connectGoogleDriveStorage: { + method: "POST", + path: "/desktop/storage/google-drive/connect", + body: z.object({}).optional(), + responses: { + 200: z.object({ url: z.string() }), + 403: z.object({ error: z.literal("upgrade_required") }), + }, + }, + testGoogleDriveStorage: { + method: "POST", + path: "/desktop/storage/google-drive/test", + body: z.object({}).optional(), + responses: { + 200: z.object({ + success: z.literal(true), + email: z.string().nullable(), + }), + 404: z.object({ error: z.literal("not_connected") }), + }, + }, + setActiveStorageProvider: { + method: "POST", + path: "/desktop/storage/set-active", + body: z.object({ + provider: z.enum(["s3", "googleDrive"]), + }), + responses: { + 200: z.object({ success: z.literal(true) }), + }, + }, + disconnectGoogleDriveStorage: { + method: "DELETE", + path: "/desktop/storage/google-drive/disconnect", + responses: { + 200: z.object({ success: z.literal(true) }), + }, + }, getProSubscribeURL: { method: "POST", path: "/desktop/subscribe", diff --git a/packages/web-api-contract/src/index.ts b/packages/web-api-contract/src/index.ts index f5df507118..cd7af38616 100644 --- a/packages/web-api-contract/src/index.ts +++ b/packages/web-api-contract/src/index.ts @@ -4,6 +4,7 @@ import { c } from "./util"; export { DesktopOrganization, + DesktopStorageIntegrations, OrganizationBrandColors, OrganizationBrandingPatchBody, OrganizationHexColor, diff --git a/packages/web-backend/src/Loom/ImportVideo.ts b/packages/web-backend/src/Loom/ImportVideo.ts index fde9f79f12..3d5ba9101a 100644 --- a/packages/web-backend/src/Loom/ImportVideo.ts +++ b/packages/web-backend/src/Loom/ImportVideo.ts @@ -72,6 +72,7 @@ export const LoomImportVideoLive = Loom.ImportVideo.toLayer( ownerId: payload.cap.userId, orgId: payload.cap.orgId, bucketId: customBucketId, + storageIntegrationId: Option.none(), source: { type: "desktopMP4" as const }, name: payload.loom.video.name, duration: Option.fromNullable(loomVideo.durationSecs), diff --git a/packages/web-backend/src/Storage/GoogleDrive.ts b/packages/web-backend/src/Storage/GoogleDrive.ts new file mode 100644 index 0000000000..9b424ac16b --- /dev/null +++ b/packages/web-backend/src/Storage/GoogleDrive.ts @@ -0,0 +1,1091 @@ +import { createHash, randomUUID } from "node:crypto"; +import { serverEnv } from "@cap/env"; +import { Storage, type User, type Video } from "@cap/web-domain"; +import { Effect, Option, Schedule } from "effect"; +import type { + GoogleDriveAccessTokenCache, + GoogleDriveIntegrationConfig, + GoogleDriveStorageQuota, + StorageRepo, +} from "./StorageRepo.ts"; + +const DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file"; +const DRIVE_API_BASE = "https://www.googleapis.com/drive/v3"; +const DRIVE_UPLOAD_BASE = "https://www.googleapis.com/upload/drive/v3"; +export const GOOGLE_DRIVE_FOLDER_MIME_TYPE = + "application/vnd.google-apps.folder"; +const DRIVE_FOLDER_OBJECT_PREFIX = ".cap-folders"; +const DRIVE_WARNING_OBJECT_PREFIX = ".cap-warnings"; +const DRIVE_WARNING_FILE_NAME = "DO_NOT_EDIT_OR_DELETE.txt"; +const DRIVE_WARNING_TEXT = + "Cap uses this folder to store and serve your video files. Do not rename, move, edit, or delete files or folders here. Changing anything in this folder can break playback, downloads, thumbnails, captions, and processing."; + +export type GoogleDriveFile = { + id: string; + name?: string; + mimeType?: string; + size?: string; + modifiedTime?: string; +}; + +type GoogleDriveListResponse = { + files?: GoogleDriveFile[]; +}; + +type GoogleDriveTokenResponse = { + access_token?: string; + expires_in?: number; + scope?: string; + token_type?: string; + refresh_token?: string; +}; + +export type GoogleDriveTokenStore = { + cacheKey: string; + getInitialAccessTokenCache: () => Effect.Effect< + Option.Option, + Storage.StorageError + >; + getAccessTokenCache: () => Effect.Effect< + Option.Option, + Storage.StorageError + >; + claimRefreshLease: ( + leaseId: string, + expiresAt: Date, + ) => Effect.Effect; + saveAccessTokenCache: ( + leaseId: string, + cache: GoogleDriveAccessTokenCache, + ) => Effect.Effect; + releaseRefreshLease: ( + leaseId: string, + ) => Effect.Effect; +}; + +export type CreateGoogleDriveUploadInput = { + integrationId: Storage.StorageIntegrationId; + ownerId: User.UserId; + videoId: Video.VideoId | null; + key: string; + contentType: string; + contentLength?: number; +}; + +const normalizeContentType = (contentType?: string | null) => + contentType?.trim() ? contentType : "application/octet-stream"; + +const parseDriveJson = async (response: Response) => { + const text = await response.text(); + if (!text) return {} as T; + return JSON.parse(text) as T; +}; + +const assertDriveResponse = async (response: Response) => { + if (response.ok || response.status === 308) return; + const text = await response.text().catch(() => ""); + throw new Error(`Google Drive request failed: ${response.status} ${text}`); +}; + +const escapeDriveQueryValue = (value: string) => + value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + +const GOOGLE_DRIVE_ACCESS_TOKEN_EXPIRY_MARGIN_MS = 60_000; +const GOOGLE_DRIVE_TOKEN_REFRESH_LEASE_MS = 15_000; +const googleDriveAccessTokenCache = new Map< + string, + GoogleDriveAccessTokenCache +>(); +const googleDriveAccessTokenRefreshes = new Map< + string, + Promise +>(); + +const getGoogleDriveAccessTokenCacheKey = ( + config: GoogleDriveIntegrationConfig, +) => createHash("sha256").update(config.refreshToken).digest("hex"); + +const isGoogleDriveAccessTokenFresh = ( + token: GoogleDriveAccessTokenCache | undefined, + invalidAccessToken?: string, +) => + Boolean( + token && + token.expiresAt.getTime() > Date.now() && + token.accessToken !== invalidAccessToken, + ); + +export const getGoogleDriveAuthUrl = ({ state }: { state: string }) => { + const env = serverEnv(); + if (!env.GOOGLE_CLIENT_ID) { + throw new Error("GOOGLE_CLIENT_ID is not configured"); + } + + const params = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri: `${env.WEB_URL}/api/desktop/storage/google-drive/callback`, + response_type: "code", + access_type: "offline", + prompt: "consent", + scope: DRIVE_FILE_SCOPE, + state, + include_granted_scopes: "true", + }); + + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; +}; + +export const exchangeGoogleDriveCode = (code: string) => + Effect.tryPromise({ + try: async () => { + const env = serverEnv(); + if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) { + throw new Error("Google OAuth is not configured"); + } + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: `${env.WEB_URL}/api/desktop/storage/google-drive/callback`, + grant_type: "authorization_code", + }), + }); + + await assertDriveResponse(response); + const tokens = await parseDriveJson(response); + if (!tokens.refresh_token) { + throw new Error("Google did not return a refresh token"); + } + return tokens; + }, + catch: (cause) => new Storage.StorageError({ cause }), + }); + +const fetchGoogleDriveAccessToken = async ( + config: GoogleDriveIntegrationConfig, +): Promise => { + const env = serverEnv(); + if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) { + throw new Error("Google OAuth is not configured"); + } + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + refresh_token: config.refreshToken, + grant_type: "refresh_token", + }), + }); + + await assertDriveResponse(response); + const token = await parseDriveJson(response); + if (!token.access_token) { + throw new Error("Google did not return an access token"); + } + + const ttlMs = Math.max( + (token.expires_in ?? 3600) * 1000 - + GOOGLE_DRIVE_ACCESS_TOKEN_EXPIRY_MARGIN_MS, + 0, + ); + return { + accessToken: token.access_token, + expiresAt: new Date(Date.now() + ttlMs), + }; +}; + +const fetchLocalGoogleDriveAccessToken = ( + config: GoogleDriveIntegrationConfig, + cacheKey: string, +) => + Effect.tryPromise({ + try: async () => { + const currentRefresh = googleDriveAccessTokenRefreshes.get(cacheKey); + if (currentRefresh) return currentRefresh; + + const refresh = fetchGoogleDriveAccessToken(config).finally(() => { + googleDriveAccessTokenRefreshes.delete(cacheKey); + }); + googleDriveAccessTokenRefreshes.set(cacheKey, refresh); + const token = await refresh; + googleDriveAccessTokenCache.set(cacheKey, token); + return token; + }, + catch: (cause) => new Storage.StorageError({ cause }), + }); + +const readFreshPersistedGoogleDriveAccessToken = ( + tokenStore: GoogleDriveTokenStore, + cacheKey: string, + invalidAccessToken?: string, +) => + tokenStore.getAccessTokenCache().pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new Storage.StorageError({ + cause: new Error("Google Drive access token is not cached"), + }), + ), + onSome: (token) => { + if (!isGoogleDriveAccessTokenFresh(token, invalidAccessToken)) { + return Effect.fail( + new Storage.StorageError({ + cause: new Error("Google Drive access token cache is stale"), + }), + ); + } + + googleDriveAccessTokenCache.set(cacheKey, token); + return Effect.succeed(token); + }, + }), + ), + ); + +const refreshPersistedGoogleDriveAccessToken = ( + config: GoogleDriveIntegrationConfig, + tokenStore: GoogleDriveTokenStore, + cacheKey: string, + invalidAccessToken?: string, +): Effect.Effect => + Effect.gen(function* () { + const leaseId = randomUUID(); + const leaseExpiresAt = new Date( + Date.now() + GOOGLE_DRIVE_TOKEN_REFRESH_LEASE_MS, + ); + const claimed = yield* tokenStore.claimRefreshLease( + leaseId, + leaseExpiresAt, + ); + + if (!claimed) { + return yield* readFreshPersistedGoogleDriveAccessToken( + tokenStore, + cacheKey, + invalidAccessToken, + ).pipe( + Effect.retry({ + times: 8, + schedule: Schedule.exponential("100 millis"), + }), + Effect.catchAll(() => + refreshPersistedGoogleDriveAccessToken( + config, + tokenStore, + cacheKey, + invalidAccessToken, + ), + ), + ); + } + + const token = yield* Effect.tryPromise({ + try: () => fetchGoogleDriveAccessToken(config), + catch: (cause) => new Storage.StorageError({ cause }), + }).pipe(Effect.tapError(() => tokenStore.releaseRefreshLease(leaseId))); + const saved = yield* tokenStore.saveAccessTokenCache(leaseId, token); + if (!saved) { + return yield* readFreshPersistedGoogleDriveAccessToken( + tokenStore, + cacheKey, + invalidAccessToken, + ); + } + googleDriveAccessTokenCache.set(cacheKey, token); + return token; + }); + +const loadGoogleDriveAccessToken = ( + config: GoogleDriveIntegrationConfig, + forceRefresh: boolean, + tokenStore?: GoogleDriveTokenStore, + invalidAccessToken?: string, +) => + Effect.gen(function* () { + const cacheKey = + tokenStore?.cacheKey ?? getGoogleDriveAccessTokenCacheKey(config); + const cached = googleDriveAccessTokenCache.get(cacheKey); + if ( + !forceRefresh && + isGoogleDriveAccessTokenFresh(cached, invalidAccessToken) + ) { + return cached as GoogleDriveAccessTokenCache; + } + if (forceRefresh) googleDriveAccessTokenCache.delete(cacheKey); + + if (!forceRefresh && tokenStore) { + const initialToken = yield* tokenStore.getInitialAccessTokenCache(); + if ( + Option.isSome(initialToken) && + isGoogleDriveAccessTokenFresh(initialToken.value, invalidAccessToken) + ) { + googleDriveAccessTokenCache.set(cacheKey, initialToken.value); + return initialToken.value; + } + } + + if (tokenStore) { + return yield* refreshPersistedGoogleDriveAccessToken( + config, + tokenStore, + cacheKey, + invalidAccessToken, + ); + } + + return yield* fetchLocalGoogleDriveAccessToken(config, cacheKey); + }); + +export const refreshGoogleDriveAccessToken = ( + config: GoogleDriveIntegrationConfig, + tokenStore?: GoogleDriveTokenStore, + invalidAccessToken?: string, +) => + loadGoogleDriveAccessToken(config, true, tokenStore, invalidAccessToken).pipe( + Effect.map((token) => token.accessToken), + ); + +const getCachedGoogleDriveAccessToken = ( + config: GoogleDriveIntegrationConfig, + tokenStore?: GoogleDriveTokenStore, +) => + loadGoogleDriveAccessToken(config, false, tokenStore).pipe( + Effect.map((token) => token.accessToken), + ); + +const clearCachedGoogleDriveAccessToken = ( + config: GoogleDriveIntegrationConfig, + tokenStore?: GoogleDriveTokenStore, +) => + Effect.sync(() => { + googleDriveAccessTokenCache.delete( + tokenStore?.cacheKey ?? getGoogleDriveAccessTokenCacheKey(config), + ); + }); + +const sendDriveRequest = ( + accessToken: string, + url: string, + init?: RequestInit, +) => + Effect.tryPromise({ + try: () => { + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${accessToken}`); + return fetch(url, { ...init, headers }); + }, + catch: (cause) => new Storage.StorageError({ cause }), + }); + +const driveFetch = ( + config: GoogleDriveIntegrationConfig, + url: string, + init?: RequestInit, + tokenStore?: GoogleDriveTokenStore, +) => + Effect.gen(function* () { + const accessToken = yield* getCachedGoogleDriveAccessToken( + config, + tokenStore, + ); + let response = yield* sendDriveRequest(accessToken, url, init); + if (response.status === 401) { + yield* clearCachedGoogleDriveAccessToken(config, tokenStore); + const refreshedAccessToken = yield* refreshGoogleDriveAccessToken( + config, + tokenStore, + accessToken, + ); + response = yield* sendDriveRequest(refreshedAccessToken, url, init); + } + yield* Effect.tryPromise({ + try: () => assertDriveResponse(response), + catch: (cause) => new Storage.StorageError({ cause }), + }); + return response; + }); + +const getDriveFileName = (key: string) => { + const parts = key.split("/").filter(Boolean); + if (parts[2] === "segments") return parts.slice(3).join("__") || "file"; + if (parts.length > 2) return parts.slice(2).join("__"); + return parts.at(-1) ?? "file"; +}; + +const getDriveFolderParts = (key: string) => { + const parts = key.split("/").filter(Boolean); + if (parts.length < 2) return []; + return parts[2] === "segments" + ? [parts[1] as string, "segments"] + : [parts[1] as string]; +}; + +const getDriveFolderObjectKey = (folderPath: string) => + `${DRIVE_FOLDER_OBJECT_PREFIX}/${folderPath}`; + +const getDriveWarningObjectKey = (folderPath: string) => + `${DRIVE_WARNING_OBJECT_PREFIX}/${folderPath}/${DRIVE_WARNING_FILE_NAME}`; + +export const getGoogleDriveUserEmail = ( + config: GoogleDriveIntegrationConfig, + tokenStore?: GoogleDriveTokenStore, +) => + driveFetch( + config, + `${DRIVE_API_BASE}/about?fields=user(emailAddress)`, + undefined, + tokenStore, + ).pipe( + Effect.flatMap((response) => + Effect.tryPromise({ + try: async () => { + const body = (await parseDriveJson<{ + user?: { emailAddress?: string }; + }>(response)) as { user?: { emailAddress?: string } }; + return body.user?.emailAddress; + }, + catch: (cause) => new Storage.StorageError({ cause }), + }), + ), + ); + +export const getGoogleDriveStorageQuota = ( + config: GoogleDriveIntegrationConfig, + tokenStore?: GoogleDriveTokenStore, +) => + driveFetch( + config, + `${DRIVE_API_BASE}/about?fields=storageQuota(limit,usage,usageInDrive,usageInDriveTrash)`, + undefined, + tokenStore, + ).pipe( + Effect.flatMap((response) => + Effect.tryPromise({ + try: async () => { + const body = await parseDriveJson<{ + storageQuota?: GoogleDriveStorageQuota; + }>(response); + return body.storageQuota ?? {}; + }, + catch: (cause) => new Storage.StorageError({ cause }), + }), + ), + ); + +export const ensureGoogleDriveFolder = ( + config: GoogleDriveIntegrationConfig, + name: string, + parentId?: string, + tokenStore?: GoogleDriveTokenStore, +) => + Effect.gen(function* () { + const query = [ + `name='${escapeDriveQueryValue(name)}'`, + "mimeType='application/vnd.google-apps.folder'", + "trashed=false", + ...(parentId ? [`'${escapeDriveQueryValue(parentId)}' in parents`] : []), + ].join(" and "); + const listUrl = `${DRIVE_API_BASE}/files?q=${encodeURIComponent(query)}&fields=files(id,name)&spaces=drive`; + const listResponse = yield* driveFetch( + config, + listUrl, + undefined, + tokenStore, + ); + const listBody = yield* Effect.tryPromise({ + try: () => parseDriveJson(listResponse), + catch: (cause) => new Storage.StorageError({ cause }), + }); + const existingId = listBody.files?.[0]?.id; + if (existingId) return existingId; + + const createResponse = yield* driveFetch( + config, + `${DRIVE_API_BASE}/files`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + mimeType: GOOGLE_DRIVE_FOLDER_MIME_TYPE, + ...(parentId ? { parents: [parentId] } : {}), + }), + }, + tokenStore, + ); + + const created = yield* Effect.tryPromise({ + try: () => parseDriveJson(createResponse), + catch: (cause) => new Storage.StorageError({ cause }), + }); + if (!created.id) { + return yield* Effect.fail( + new Storage.StorageError({ + cause: new Error("Google Drive folder creation did not return an id"), + }), + ); + } + return created.id; + }); + +const createGoogleDriveFolderWithId = ( + config: GoogleDriveIntegrationConfig, + id: string, + name: string, + parentId: string, + tokenStore?: GoogleDriveTokenStore, +) => + driveFetch( + config, + `${DRIVE_API_BASE}/files?fields=id,name`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id, + name, + mimeType: GOOGLE_DRIVE_FOLDER_MIME_TYPE, + parents: [parentId], + }), + }, + tokenStore, + ).pipe(Effect.asVoid); + +const createGoogleDriveTextFileWithId = ({ + config, + id, + name, + parentId, + content, + tokenStore, +}: { + config: GoogleDriveIntegrationConfig; + id: string; + name: string; + parentId: string; + content: string; + tokenStore?: GoogleDriveTokenStore; +}) => { + const boundary = `cap_drive_boundary_${id}`; + const metadata = JSON.stringify({ + id, + name, + mimeType: "text/plain", + parents: [parentId], + }); + const body = [ + `--${boundary}`, + "Content-Type: application/json; charset=UTF-8", + "", + metadata, + `--${boundary}`, + "Content-Type: text/plain; charset=UTF-8", + "", + content, + `--${boundary}--`, + "", + ].join("\r\n"); + + return driveFetch( + config, + `${DRIVE_UPLOAD_BASE}/files?uploadType=multipart&fields=id,name,mimeType,size`, + { + method: "POST", + headers: { "Content-Type": `multipart/related; boundary=${boundary}` }, + body, + }, + tokenStore, + ).pipe(Effect.asVoid); +}; + +const waitForReservedGoogleDriveObject = ( + repo: StorageRepo, + integrationId: Storage.StorageIntegrationId, + objectKey: string, +) => + repo.getObjectByKey(integrationId, objectKey).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new Storage.StorageError({ + cause: new Error("Google Drive object reservation not found"), + }), + ), + onSome: (object) => + object.uploadStatus === "complete" + ? Effect.succeed(object.providerObjectId) + : Effect.fail( + new Storage.StorageError({ + cause: new Error("Google Drive object reservation pending"), + }), + ), + }), + ), + Effect.retry({ + times: 8, + schedule: Schedule.exponential("100 millis"), + }), + ); + +const getOrCreateGoogleDriveFolder = ({ + repo, + config, + input, + folderPath, + name, + parentId, + tokenStore, +}: { + repo: StorageRepo; + config: GoogleDriveIntegrationConfig; + input: CreateGoogleDriveUploadInput; + folderPath: string; + name: string; + parentId: string; + tokenStore?: GoogleDriveTokenStore; +}) => + Effect.gen(function* () { + const folderObjectKey = getDriveFolderObjectKey(folderPath); + const existing = yield* repo.getObjectByKey( + input.integrationId, + folderObjectKey, + ); + if (Option.isSome(existing)) { + if (existing.value.uploadStatus === "complete") { + return existing.value.providerObjectId; + } + return yield* waitForReservedGoogleDriveObject( + repo, + input.integrationId, + folderObjectKey, + ); + } + + const folderId = yield* generateGoogleDriveFileId(config, tokenStore); + const reserved = yield* repo.reserveObject({ + integrationId: input.integrationId, + ownerId: input.ownerId, + videoId: input.videoId, + objectKey: folderObjectKey, + providerObjectId: folderId, + uploadStatus: "pending", + contentType: GOOGLE_DRIVE_FOLDER_MIME_TYPE, + metadata: { + videoId: input.videoId ?? undefined, + fileName: name, + contentType: GOOGLE_DRIVE_FOLDER_MIME_TYPE, + }, + }); + + if (reserved.providerObjectId !== folderId) { + return yield* waitForReservedGoogleDriveObject( + repo, + input.integrationId, + folderObjectKey, + ); + } + + yield* createGoogleDriveFolderWithId( + config, + folderId, + name, + parentId, + tokenStore, + ).pipe( + Effect.tapError(() => + repo.deleteObjectByKey(input.integrationId, folderObjectKey), + ), + ); + yield* repo.markObjectComplete(input.integrationId, folderObjectKey); + return folderId; + }); + +const ensureGoogleDriveWarningFile = ({ + repo, + config, + input, + folderPath, + parentId, + tokenStore, +}: { + repo: StorageRepo; + config: GoogleDriveIntegrationConfig; + input: CreateGoogleDriveUploadInput; + folderPath: string; + parentId: string; + tokenStore?: GoogleDriveTokenStore; +}) => + Effect.gen(function* () { + const warningObjectKey = getDriveWarningObjectKey(folderPath); + const existing = yield* repo.getObjectByKey( + input.integrationId, + warningObjectKey, + ); + if (Option.isSome(existing)) { + if (existing.value.uploadStatus === "complete") return; + yield* waitForReservedGoogleDriveObject( + repo, + input.integrationId, + warningObjectKey, + ); + return; + } + + const warningFileId = yield* generateGoogleDriveFileId(config, tokenStore); + const reserved = yield* repo.reserveObject({ + integrationId: input.integrationId, + ownerId: input.ownerId, + videoId: input.videoId, + objectKey: warningObjectKey, + providerObjectId: warningFileId, + uploadStatus: "pending", + contentType: "text/plain", + contentLength: DRIVE_WARNING_TEXT.length, + metadata: { + videoId: input.videoId ?? undefined, + fileName: DRIVE_WARNING_FILE_NAME, + contentType: "text/plain", + }, + }); + + if (reserved.providerObjectId !== warningFileId) { + yield* waitForReservedGoogleDriveObject( + repo, + input.integrationId, + warningObjectKey, + ); + return; + } + + yield* createGoogleDriveTextFileWithId({ + config, + id: warningFileId, + name: DRIVE_WARNING_FILE_NAME, + parentId, + content: DRIVE_WARNING_TEXT, + tokenStore, + }).pipe( + Effect.tapError(() => + repo.deleteObjectByKey(input.integrationId, warningObjectKey), + ), + ); + yield* repo.markObjectComplete( + input.integrationId, + warningObjectKey, + DRIVE_WARNING_TEXT.length, + ); + }); + +const getGoogleDriveUploadParentId = ( + repo: StorageRepo, + config: GoogleDriveIntegrationConfig, + input: CreateGoogleDriveUploadInput, + tokenStore?: GoogleDriveTokenStore, +) => + Effect.gen(function* () { + const folderParts = getDriveFolderParts(input.key); + let parentId = config.folderId; + const pathParts: string[] = []; + let videoFolderId: string | null = null; + let videoFolderPath: string | null = null; + + for (const folderName of folderParts) { + pathParts.push(folderName); + parentId = yield* getOrCreateGoogleDriveFolder({ + repo, + config, + input, + folderPath: pathParts.join("/"), + name: folderName, + parentId, + tokenStore, + }); + if (pathParts.length === 1) { + videoFolderId = parentId; + videoFolderPath = pathParts.join("/"); + } + } + + if (videoFolderId && videoFolderPath) { + yield* ensureGoogleDriveWarningFile({ + repo, + config, + input, + folderPath: videoFolderPath, + parentId: videoFolderId, + tokenStore, + }); + } + + return parentId; + }); + +const generateGoogleDriveFileId = ( + config: GoogleDriveIntegrationConfig, + tokenStore?: GoogleDriveTokenStore, +) => + driveFetch( + config, + `${DRIVE_API_BASE}/files/generateIds?count=1&space=drive&type=files`, + undefined, + tokenStore, + ).pipe( + Effect.flatMap((response) => + Effect.tryPromise({ + try: async () => { + const body = await parseDriveJson<{ ids?: string[] }>(response); + const id = body.ids?.[0]; + if (!id) throw new Error("Google Drive did not return a file id"); + return id; + }, + catch: (cause) => new Storage.StorageError({ cause }), + }), + ), + ); + +export const createGoogleDriveResumableUpload = ( + repo: StorageRepo, + config: GoogleDriveIntegrationConfig, + input: CreateGoogleDriveUploadInput, + tokenStore?: GoogleDriveTokenStore, +) => + Effect.gen(function* () { + const contentType = normalizeContentType(input.contentType); + const [parentId, fileId] = yield* Effect.all([ + getGoogleDriveUploadParentId(repo, config, input, tokenStore), + generateGoogleDriveFileId(config, tokenStore), + ]); + const headers: Record = { + "Content-Type": "application/json; charset=UTF-8", + "X-Upload-Content-Type": contentType, + }; + if (input.contentLength !== undefined) { + headers["X-Upload-Content-Length"] = input.contentLength.toString(); + } + + const response = yield* driveFetch( + config, + `${DRIVE_UPLOAD_BASE}/files?uploadType=resumable&fields=id,name,mimeType,size`, + { + method: "POST", + headers, + body: JSON.stringify({ + id: fileId, + name: getDriveFileName(input.key), + mimeType: contentType, + parents: [parentId], + appProperties: { + capObjectKey: input.key, + }, + }), + }, + tokenStore, + ); + const uploadUrl = response.headers.get("Location"); + if (!uploadUrl) { + return yield* Effect.fail( + new Storage.StorageError({ + cause: new Error("Google Drive did not return an upload URL"), + }), + ); + } + + yield* repo.upsertObject({ + integrationId: input.integrationId, + ownerId: input.ownerId, + videoId: input.videoId, + objectKey: input.key, + providerObjectId: fileId, + uploadSessionUrl: uploadUrl, + uploadStatus: "pending", + contentType, + contentLength: input.contentLength ?? null, + metadata: { + videoId: input.videoId ?? undefined, + fileName: getDriveFileName(input.key), + contentType, + }, + }); + + return uploadUrl; + }); + +export const getGoogleDriveFileMetadata = ( + config: GoogleDriveIntegrationConfig, + fileId: string, + tokenStore?: GoogleDriveTokenStore, +) => + driveFetch( + config, + `${DRIVE_API_BASE}/files/${encodeURIComponent(fileId)}?fields=id,name,mimeType,size`, + undefined, + tokenStore, + ).pipe( + Effect.flatMap((response) => + Effect.tryPromise({ + try: () => parseDriveJson(response), + catch: (cause) => new Storage.StorageError({ cause }), + }), + ), + ); + +export const findGoogleDriveFileByObjectKey = ( + config: GoogleDriveIntegrationConfig, + key: string, + tokenStore?: GoogleDriveTokenStore, +) => { + const query = [ + `appProperties has { key='capObjectKey' and value='${escapeDriveQueryValue(key)}' }`, + "trashed=false", + ].join(" and "); + const params = new URLSearchParams({ + q: query, + fields: "files(id,name,mimeType,size,modifiedTime)", + orderBy: "modifiedTime desc", + pageSize: "10", + spaces: "drive", + }); + + return driveFetch( + config, + `${DRIVE_API_BASE}/files?${params.toString()}`, + undefined, + tokenStore, + ).pipe( + Effect.flatMap((response) => + Effect.tryPromise({ + try: () => parseDriveJson(response), + catch: (cause) => new Storage.StorageError({ cause }), + }), + ), + Effect.map((body) => { + const files = body.files ?? []; + return Option.fromNullable( + files.find((file) => Number(file.size ?? 0) > 0) ?? files[0], + ); + }), + ); +}; + +export const getGoogleDriveObjectText = ( + config: GoogleDriveIntegrationConfig, + fileId: string, + tokenStore?: GoogleDriveTokenStore, +) => + driveFetch( + config, + `${DRIVE_API_BASE}/files/${encodeURIComponent(fileId)}?alt=media`, + undefined, + tokenStore, + ).pipe( + Effect.flatMap((response) => + Effect.tryPromise({ + try: () => response.text(), + catch: (cause) => new Storage.StorageError({ cause }), + }), + ), + ); + +export const getGoogleDriveObjectResponse = ( + config: GoogleDriveIntegrationConfig, + fileId: string, + range?: string | null, + tokenStore?: GoogleDriveTokenStore, +) => + Effect.gen(function* () { + const headers: Record = {}; + if (range) headers.Range = range; + const response = yield* driveFetch( + config, + `${DRIVE_API_BASE}/files/${encodeURIComponent(fileId)}?alt=media`, + { headers }, + tokenStore, + ); + return response; + }); + +export const deleteGoogleDriveFile = ( + config: GoogleDriveIntegrationConfig, + fileId: string, + tokenStore?: GoogleDriveTokenStore, +) => + driveFetch( + config, + `${DRIVE_API_BASE}/files/${encodeURIComponent(fileId)}`, + { + method: "DELETE", + }, + tokenStore, + ).pipe(Effect.asVoid); + +export const copyGoogleDriveFile = ({ + repo, + config, + sourceFileId, + input, + tokenStore, +}: { + repo: StorageRepo; + config: GoogleDriveIntegrationConfig; + sourceFileId: string; + input: CreateGoogleDriveUploadInput; + tokenStore?: GoogleDriveTokenStore; +}) => + Effect.gen(function* () { + const parentId = yield* getGoogleDriveUploadParentId( + repo, + config, + input, + tokenStore, + ); + const response = yield* driveFetch( + config, + `${DRIVE_API_BASE}/files/${encodeURIComponent(sourceFileId)}/copy?fields=id,name,mimeType,size`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: getDriveFileName(input.key), + parents: [parentId], + appProperties: { + capObjectKey: input.key, + }, + }), + }, + tokenStore, + ); + const copied = yield* Effect.tryPromise({ + try: () => parseDriveJson(response), + catch: (cause) => new Storage.StorageError({ cause }), + }); + if (!copied.id) { + return yield* Effect.fail( + new Storage.StorageError({ + cause: new Error("Google Drive copy did not return an id"), + }), + ); + } + yield* repo.upsertObject({ + integrationId: input.integrationId, + ownerId: input.ownerId, + videoId: input.videoId, + objectKey: input.key, + providerObjectId: copied.id, + uploadStatus: "complete", + contentType: copied.mimeType ?? input.contentType, + contentLength: copied.size ? Number(copied.size) : null, + }); + }); + +export const parseVideoIdFromObjectKey = (key: string) => + Option.fromNullable(key.split("/")[1]).pipe(Option.filter((id) => id !== "")); diff --git a/packages/web-backend/src/Storage/SignedObject.ts b/packages/web-backend/src/Storage/SignedObject.ts new file mode 100644 index 0000000000..f83b6ba76a --- /dev/null +++ b/packages/web-backend/src/Storage/SignedObject.ts @@ -0,0 +1,50 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { serverEnv } from "@cap/env"; + +type StorageObjectTokenPayload = { + videoId: string; + key: string; + expiresAt: number; +}; + +const encode = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + +const sign = (payload: string) => + createHmac("sha256", serverEnv().NEXTAUTH_SECRET) + .update(payload) + .digest("base64url"); + +export function createStorageObjectToken( + payload: Omit, + ttlSeconds = 3600, +) { + const encodedPayload = encode({ + ...payload, + expiresAt: Date.now() + ttlSeconds * 1000, + }); + return `${encodedPayload}.${sign(encodedPayload)}`; +} + +export function verifyStorageObjectToken(token: string) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) return null; + + const expected = sign(encodedPayload); + const signatureBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expected); + if (signatureBuffer.length !== expectedBuffer.length) return null; + if (!timingSafeEqual(signatureBuffer, expectedBuffer)) return null; + + let payload: StorageObjectTokenPayload; + try { + payload = JSON.parse( + Buffer.from(encodedPayload, "base64url").toString("utf8"), + ) as StorageObjectTokenPayload; + } catch { + return null; + } + + if (payload.expiresAt < Date.now()) return null; + return payload; +} diff --git a/packages/web-backend/src/Storage/StorageRepo.ts b/packages/web-backend/src/Storage/StorageRepo.ts new file mode 100644 index 0000000000..d55fe2f0f7 --- /dev/null +++ b/packages/web-backend/src/Storage/StorageRepo.ts @@ -0,0 +1,468 @@ +import { createHash } from "node:crypto"; +import { decrypt, encrypt } from "@cap/database/crypto"; +import { nanoId } from "@cap/database/helpers"; +import * as Db from "@cap/database/schema"; +import { Storage, type User, type Video } from "@cap/web-domain"; +import * as Dz from "drizzle-orm"; +import { Effect, Option } from "effect"; + +import { Database } from "../Database.ts"; + +export const getObjectKeyHash = (key: string) => + createHash("sha256").update(key).digest("hex"); + +const escapeLikePattern = (value: string) => + value.replace(/[\\%_]/g, (match) => `\\${match}`); + +export type GoogleDriveIntegrationConfig = { + refreshToken: string; + folderId: string; + email?: string; + scope?: string; +}; + +export type GoogleDriveStorageQuota = { + limit?: string | null; + usage?: string | null; + usageInDrive?: string | null; + usageInDriveTrash?: string | null; +}; + +export type GoogleDriveStorageQuotaCache = GoogleDriveStorageQuota & { + fetchedAt: string; +}; + +export type GoogleDriveAccessTokenCache = { + accessToken: string; + expiresAt: Date; +}; + +export type StorageObjectInput = { + integrationId: Storage.StorageIntegrationId; + ownerId: User.UserId; + videoId: Video.VideoId | null; + objectKey: string; + providerObjectId: string; + uploadSessionUrl?: string | null; + uploadStatus?: "pending" | "complete" | "error"; + contentType?: string | null; + contentLength?: number | null; + metadata?: Storage.StorageObjectMetadata | null; +}; + +const getAffectedRows = (result: unknown) => { + if (Array.isArray(result)) { + return ( + (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0 + ); + } + + return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0; +}; + +export class StorageRepo extends Effect.Service()("StorageRepo", { + effect: Effect.gen(function* () { + const db = yield* Database; + + const decodeGoogleDriveAccessTokenCache = Effect.fn( + "StorageRepo.decodeGoogleDriveAccessTokenCache", + )( + (input: { + googleDriveAccessToken: string | null; + googleDriveAccessTokenExpiresAt: Date | null; + }) => + Effect.gen(function* () { + if ( + !input.googleDriveAccessToken || + !input.googleDriveAccessTokenExpiresAt + ) { + return Option.none(); + } + + const accessToken = yield* Effect.tryPromise({ + try: () => decrypt(input.googleDriveAccessToken as string), + catch: (cause) => new Storage.StorageError({ cause }), + }); + + return Option.some({ + accessToken, + expiresAt: input.googleDriveAccessTokenExpiresAt, + }); + }), + ); + + const getActiveIntegrationForUser = Effect.fn( + "StorageRepo.getActiveIntegrationForUser", + )((userId: User.UserId) => + Effect.gen(function* () { + const [integration] = yield* db.use((db) => + db + .select() + .from(Db.storageIntegrations) + .where( + Dz.and( + Dz.eq(Db.storageIntegrations.ownerId, userId), + Dz.eq(Db.storageIntegrations.active, true), + Dz.eq(Db.storageIntegrations.status, "active"), + ), + ), + ); + + return Option.fromNullable(integration); + }), + ); + + const getIntegrationById = Effect.fn("StorageRepo.getIntegrationById")( + (id: Storage.StorageIntegrationId) => + Effect.gen(function* () { + const [integration] = yield* db.use((db) => + db + .select() + .from(Db.storageIntegrations) + .where(Dz.eq(Db.storageIntegrations.id, id)), + ); + + return Option.fromNullable(integration); + }), + ); + + const getGoogleDriveConfig = Effect.fn("StorageRepo.getGoogleDriveConfig")( + (integration: typeof Db.storageIntegrations.$inferSelect) => + Effect.tryPromise({ + try: async () => + JSON.parse( + await decrypt(integration.encryptedConfig), + ) as GoogleDriveIntegrationConfig, + catch: (cause) => new Storage.StorageError({ cause }), + }), + ); + + const getGoogleDriveAccessTokenCache = Effect.fn( + "StorageRepo.getGoogleDriveAccessTokenCache", + )((integration: typeof Db.storageIntegrations.$inferSelect) => + decodeGoogleDriveAccessTokenCache({ + googleDriveAccessToken: integration.googleDriveAccessToken, + googleDriveAccessTokenExpiresAt: + integration.googleDriveAccessTokenExpiresAt, + }), + ); + + const getGoogleDriveAccessTokenCacheById = Effect.fn( + "StorageRepo.getGoogleDriveAccessTokenCacheById", + )((id: Storage.StorageIntegrationId) => + Effect.gen(function* () { + const [integration] = yield* db.use((db) => + db + .select({ + googleDriveAccessToken: + Db.storageIntegrations.googleDriveAccessToken, + googleDriveAccessTokenExpiresAt: + Db.storageIntegrations.googleDriveAccessTokenExpiresAt, + }) + .from(Db.storageIntegrations) + .where(Dz.eq(Db.storageIntegrations.id, id)) + .limit(1), + ); + + if (!integration) return Option.none(); + return yield* decodeGoogleDriveAccessTokenCache(integration); + }), + ); + + const claimGoogleDriveTokenRefreshLease = Effect.fn( + "StorageRepo.claimGoogleDriveTokenRefreshLease", + )( + ( + id: Storage.StorageIntegrationId, + leaseId: string, + leaseExpiresAt: Date, + ) => + Effect.gen(function* () { + const result = yield* db.use((db) => + db + .update(Db.storageIntegrations) + .set({ + googleDriveTokenRefreshLeaseId: leaseId, + googleDriveTokenRefreshLeaseExpiresAt: leaseExpiresAt, + updatedAt: new Date(), + }) + .where( + Dz.and( + Dz.eq(Db.storageIntegrations.id, id), + Dz.or( + Dz.isNull( + Db.storageIntegrations + .googleDriveTokenRefreshLeaseExpiresAt, + ), + Dz.lt( + Db.storageIntegrations + .googleDriveTokenRefreshLeaseExpiresAt, + new Date(), + ), + ), + ), + ), + ); + + return getAffectedRows(result) > 0; + }), + ); + + const saveGoogleDriveAccessTokenCache = Effect.fn( + "StorageRepo.saveGoogleDriveAccessTokenCache", + )( + ( + id: Storage.StorageIntegrationId, + leaseId: string, + cache: GoogleDriveAccessTokenCache, + ) => + Effect.gen(function* () { + const googleDriveAccessToken = yield* Effect.tryPromise({ + try: () => encrypt(cache.accessToken), + catch: (cause) => new Storage.StorageError({ cause }), + }); + + const result = yield* db.use((db) => + db + .update(Db.storageIntegrations) + .set({ + googleDriveAccessToken, + googleDriveAccessTokenExpiresAt: cache.expiresAt, + googleDriveTokenRefreshLeaseId: null, + googleDriveTokenRefreshLeaseExpiresAt: null, + updatedAt: new Date(), + }) + .where( + Dz.and( + Dz.eq(Db.storageIntegrations.id, id), + Dz.eq( + Db.storageIntegrations.googleDriveTokenRefreshLeaseId, + leaseId, + ), + ), + ), + ); + + return getAffectedRows(result) > 0; + }), + ); + + const releaseGoogleDriveTokenRefreshLease = Effect.fn( + "StorageRepo.releaseGoogleDriveTokenRefreshLease", + )((id: Storage.StorageIntegrationId, leaseId: string) => + db.use((db) => + db + .update(Db.storageIntegrations) + .set({ + googleDriveTokenRefreshLeaseId: null, + googleDriveTokenRefreshLeaseExpiresAt: null, + updatedAt: new Date(), + }) + .where( + Dz.and( + Dz.eq(Db.storageIntegrations.id, id), + Dz.eq( + Db.storageIntegrations.googleDriveTokenRefreshLeaseId, + leaseId, + ), + ), + ), + ), + ); + + const upsertObject = Effect.fn("StorageRepo.upsertObject")( + (input: StorageObjectInput) => + Effect.gen(function* () { + const objectKeyHash = getObjectKeyHash(input.objectKey); + const uploadSessionUrl = input.uploadSessionUrl + ? yield* Effect.tryPromise({ + try: () => encrypt(input.uploadSessionUrl as string), + catch: (cause) => new Storage.StorageError({ cause }), + }) + : null; + + const value = { + id: Storage.StorageObjectId.make(nanoId()), + integrationId: input.integrationId, + ownerId: input.ownerId, + videoId: input.videoId, + objectKey: input.objectKey, + objectKeyHash, + providerObjectId: input.providerObjectId, + uploadSessionUrl, + uploadStatus: input.uploadStatus ?? "pending", + contentType: input.contentType ?? null, + contentLength: input.contentLength ?? null, + metadata: input.metadata ?? null, + }; + + yield* db.use((db) => + db + .insert(Db.storageObjects) + .values(value) + .onDuplicateKeyUpdate({ + set: { + providerObjectId: value.providerObjectId, + uploadSessionUrl: value.uploadSessionUrl, + uploadStatus: value.uploadStatus, + contentType: value.contentType, + contentLength: value.contentLength, + metadata: value.metadata, + updatedAt: new Date(), + }, + }), + ); + }), + ); + + const reserveObject = Effect.fn("StorageRepo.reserveObject")( + (input: StorageObjectInput) => + Effect.gen(function* () { + const objectKeyHash = getObjectKeyHash(input.objectKey); + const uploadSessionUrl = input.uploadSessionUrl + ? yield* Effect.tryPromise({ + try: () => encrypt(input.uploadSessionUrl as string), + catch: (cause) => new Storage.StorageError({ cause }), + }) + : null; + + yield* db.use((db) => + db + .insert(Db.storageObjects) + .values({ + id: Storage.StorageObjectId.make(nanoId()), + integrationId: input.integrationId, + ownerId: input.ownerId, + videoId: input.videoId, + objectKey: input.objectKey, + objectKeyHash, + providerObjectId: input.providerObjectId, + uploadSessionUrl, + uploadStatus: input.uploadStatus ?? "pending", + contentType: input.contentType ?? null, + contentLength: input.contentLength ?? null, + metadata: input.metadata ?? null, + }) + .onDuplicateKeyUpdate({ + set: { + id: Dz.sql`${Db.storageObjects.id}`, + }, + }), + ); + + const object = yield* getObjectByKey( + input.integrationId, + input.objectKey, + ); + return yield* Option.match(object, { + onNone: () => + Effect.fail( + new Storage.StorageError({ + cause: new Error("Storage object reservation failed"), + }), + ), + onSome: Effect.succeed, + }); + }), + ); + + const getObjectByKey = Effect.fn("StorageRepo.getObjectByKey")( + (integrationId: Storage.StorageIntegrationId, key: string) => + Effect.gen(function* () { + const [object] = yield* db.use((db) => + db + .select() + .from(Db.storageObjects) + .where( + Dz.and( + Dz.eq(Db.storageObjects.integrationId, integrationId), + Dz.eq(Db.storageObjects.objectKeyHash, getObjectKeyHash(key)), + ), + ), + ); + + return Option.fromNullable(object).pipe( + Option.filter((object) => object.objectKey === key), + ); + }), + ); + + const listObjectsByPrefix = Effect.fn("StorageRepo.listObjectsByPrefix")( + ( + integrationId: Storage.StorageIntegrationId, + prefix: string | undefined, + maxKeys: number | undefined, + ) => + db.use((db) => { + const where = prefix + ? Dz.and( + Dz.eq(Db.storageObjects.integrationId, integrationId), + Dz.sql`BINARY ${Db.storageObjects.objectKey} LIKE ${`${escapeLikePattern(prefix)}%`}`, + ) + : Dz.eq(Db.storageObjects.integrationId, integrationId); + + return db + .select() + .from(Db.storageObjects) + .where(where) + .orderBy(Db.storageObjects.objectKey) + .limit(maxKeys ?? 1000); + }), + ); + + const markObjectComplete = Effect.fn("StorageRepo.markObjectComplete")( + ( + integrationId: Storage.StorageIntegrationId, + key: string, + contentLength?: number | null, + ) => + db.use((db) => + db + .update(Db.storageObjects) + .set({ + uploadStatus: "complete", + contentLength: contentLength ?? undefined, + updatedAt: new Date(), + }) + .where( + Dz.and( + Dz.eq(Db.storageObjects.integrationId, integrationId), + Dz.eq(Db.storageObjects.objectKeyHash, getObjectKeyHash(key)), + ), + ), + ), + ); + + const deleteObjectByKey = Effect.fn("StorageRepo.deleteObjectByKey")( + (integrationId: Storage.StorageIntegrationId, key: string) => + db.use((db) => + db + .delete(Db.storageObjects) + .where( + Dz.and( + Dz.eq(Db.storageObjects.integrationId, integrationId), + Dz.eq(Db.storageObjects.objectKeyHash, getObjectKeyHash(key)), + ), + ), + ), + ); + + return { + getActiveIntegrationForUser, + getIntegrationById, + getGoogleDriveConfig, + getGoogleDriveAccessTokenCache, + getGoogleDriveAccessTokenCacheById, + claimGoogleDriveTokenRefreshLease, + saveGoogleDriveAccessTokenCache, + releaseGoogleDriveTokenRefreshLease, + upsertObject, + reserveObject, + getObjectByKey, + listObjectsByPrefix, + markObjectComplete, + deleteObjectByKey, + }; + }), + dependencies: [Database.Default], +}) {} diff --git a/packages/web-backend/src/Storage/index.ts b/packages/web-backend/src/Storage/index.ts new file mode 100644 index 0000000000..3420f8db29 --- /dev/null +++ b/packages/web-backend/src/Storage/index.ts @@ -0,0 +1,811 @@ +import type * as S3 from "@aws-sdk/client-s3"; +import type * as Db from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import { + Storage as StorageDomain, + type User, + type Video, +} from "@cap/web-domain"; +import { Effect, Option } from "effect"; + +import { S3Buckets } from "../S3Buckets/index.ts"; +import type { S3BucketAccess } from "../S3Buckets/S3BucketAccess.ts"; +import { + copyGoogleDriveFile, + createGoogleDriveResumableUpload, + deleteGoogleDriveFile, + findGoogleDriveFileByObjectKey, + GOOGLE_DRIVE_FOLDER_MIME_TYPE, + type GoogleDriveFile, + type GoogleDriveTokenStore, + getGoogleDriveFileMetadata, + getGoogleDriveObjectResponse, + getGoogleDriveObjectText, + parseVideoIdFromObjectKey, +} from "./GoogleDrive.ts"; +import { createStorageObjectToken } from "./SignedObject.ts"; +import type { GoogleDriveIntegrationConfig } from "./StorageRepo.ts"; +import { StorageRepo } from "./StorageRepo.ts"; + +type UploadTargetInput = { + contentType: string; + contentLength?: number; + fields?: Record; + method?: "post" | "put"; +}; + +const toS3UploadTarget = (data: { + url: string; + fields: Record; +}): StorageDomain.UploadTarget => ({ + type: "s3Post", + url: data.url, + fields: data.fields, +}); + +const toPutUploadTarget = ( + url: string, + contentType: string, +): StorageDomain.UploadTarget => ({ + type: "put", + url, + headers: { + "Content-Type": contentType, + }, +}); + +const toDriveUploadTarget = ( + url: string, + contentType: string, +): StorageDomain.UploadTarget => ({ + type: "driveResumable", + url, + headers: { + "Content-Type": contentType, + }, +}); + +const getGoogleDriveUploadHeaders = ( + contentType: string, + contentLength: number, +) => ({ + "Content-Type": contentType, + "Content-Length": contentLength.toString(), + ...(contentLength > 0 + ? { + "Content-Range": `bytes 0-${contentLength - 1}/${contentLength}`, + } + : {}), +}); + +const parseSourceKey = (source: string) => { + const parts = source.split("/"); + return parts.length > 1 ? parts.slice(1).join("/") : source; +}; + +const requireDriveObject = ( + repo: StorageRepo, + integrationId: StorageDomain.StorageIntegrationId, + key: string, +) => + repo.getObjectByKey(integrationId, key).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new StorageDomain.StorageError({ + cause: new Error(`Storage object not found: ${key}`), + }), + ), + onSome: Effect.succeed, + }), + ), + ); + +const createDriveObjectUrl = (key: string, ttlSeconds = 3600) => + parseVideoIdFromObjectKey(key).pipe( + Option.match({ + onNone: () => + Effect.fail( + new StorageDomain.StorageError({ + cause: new Error(`Could not resolve video id from key: ${key}`), + }), + ), + onSome: (videoId) => + Effect.sync(() => { + const token = createStorageObjectToken({ videoId, key }, ttlSeconds); + const params = new URLSearchParams({ videoId, key, token }); + return `${serverEnv().WEB_URL}/api/storage/object?${params.toString()}`; + }), + }), + ); + +const mapStorageError = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((cause) => new StorageDomain.StorageError({ cause })), + ); + +const makeGoogleDriveTokenStore = ( + repo: StorageRepo, + integration: typeof Db.storageIntegrations.$inferSelect, +): GoogleDriveTokenStore => ({ + cacheKey: integration.id, + getInitialAccessTokenCache: () => + mapStorageError(repo.getGoogleDriveAccessTokenCache(integration)), + getAccessTokenCache: () => + mapStorageError(repo.getGoogleDriveAccessTokenCacheById(integration.id)), + claimRefreshLease: (leaseId, expiresAt) => + mapStorageError( + repo.claimGoogleDriveTokenRefreshLease( + integration.id, + leaseId, + expiresAt, + ), + ), + saveAccessTokenCache: (leaseId, cache) => + mapStorageError( + repo.saveGoogleDriveAccessTokenCache(integration.id, leaseId, cache), + ), + releaseRefreshLease: (leaseId) => + mapStorageError( + repo.releaseGoogleDriveTokenRefreshLease(integration.id, leaseId), + ), +}); + +const makeS3Access = (s3: S3BucketAccess) => ({ + provider: "s3" as const, + bucketName: s3.bucketName, + isPathStyle: s3.isPathStyle, + getSignedObjectUrl: (key: string) => + mapStorageError(s3.getSignedObjectUrl(key)), + getInternalSignedObjectUrl: (key: string) => + mapStorageError(s3.getInternalSignedObjectUrl(key)), + getObject: (key: string) => mapStorageError(s3.getObject(key)), + listObjects: (input: { + prefix?: string; + maxKeys?: number; + continuationToken?: string; + }) => + mapStorageError(s3.listObjects(input)).pipe( + Effect.map((result) => ({ + Contents: result.Contents?.map((object) => ({ + Key: object.Key, + Size: object.Size, + })), + KeyCount: result.KeyCount, + IsTruncated: result.IsTruncated, + NextContinuationToken: result.NextContinuationToken, + })), + ), + headObject: (key: string) => + mapStorageError(s3.headObject(key)).pipe( + Effect.map((result) => ({ + ContentLength: result.ContentLength, + ContentType: result.ContentType, + Metadata: result.Metadata, + })), + ), + putObject: ( + key: string, + body: Parameters[1], + fields?: Parameters[2], + ) => mapStorageError(s3.putObject(key, body, fields)).pipe(Effect.asVoid), + copyObject: ( + source: string, + key: string, + args?: Omit, + ) => mapStorageError(s3.copyObject(source, key, args)).pipe(Effect.asVoid), + deleteObject: (key: string) => + mapStorageError(s3.deleteObject(key)).pipe(Effect.asVoid), + deleteObjects: (objects: Array<{ Key?: string }>) => + mapStorageError( + s3.deleteObjects( + objects + .filter((object): object is { Key: string } => Boolean(object.Key)) + .map((object) => ({ Key: object.Key })), + ), + ).pipe(Effect.asVoid), + getPresignedPutUrl: ( + key: string, + args?: Omit, + signingArgs?: Parameters[2], + ) => mapStorageError(s3.getPresignedPutUrl(key, args, signingArgs)), + getInternalPresignedPutUrl: ( + key: string, + args?: Omit, + signingArgs?: Parameters[2], + ) => mapStorageError(s3.getInternalPresignedPutUrl(key, args, signingArgs)), + getPresignedPostUrl: ( + key: string, + args: Parameters[1], + ) => mapStorageError(s3.getPresignedPostUrl(key, args)), + multipart: { + create: ( + key: string, + args?: Omit, + ) => mapStorageError(s3.multipart.create(key, args)), + getPresignedUploadPartUrl: ( + key: string, + uploadId: string, + partNumber: number, + args?: Omit< + S3.UploadPartCommandInput, + "Key" | "Bucket" | "PartNumber" | "UploadId" + >, + ) => + mapStorageError( + s3.multipart.getPresignedUploadPartUrl(key, uploadId, partNumber, args), + ), + complete: ( + key: string, + uploadId: string, + args?: Omit< + S3.CompleteMultipartUploadCommandInput, + "Key" | "Bucket" | "UploadId" + >, + ) => mapStorageError(s3.multipart.complete(key, uploadId, args)), + abort: ( + key: string, + uploadId: string, + args?: Omit< + S3.AbortMultipartUploadCommandInput, + "Key" | "Bucket" | "UploadId" + >, + ) => mapStorageError(s3.multipart.abort(key, uploadId, args)), + }, + createUploadTarget: (key: string, input: UploadTargetInput) => + Effect.gen(function* () { + if (input.method === "put") { + const url = yield* s3 + .getPresignedPutUrl( + key, + { ContentType: input.contentType }, + { expiresIn: 1800 }, + ) + .pipe(mapStorageError); + return toPutUploadTarget(url, input.contentType); + } + + const data = yield* s3 + .getPresignedPostUrl(key, { + Fields: { + "Content-Type": input.contentType, + ...(input.fields ?? {}), + }, + Expires: 1800, + }) + .pipe(mapStorageError); + return toS3UploadTarget(data); + }), +}); + +const parseGoogleDriveContentLength = (file: GoogleDriveFile) => { + if (!file.size) return null; + const contentLength = Number(file.size); + return Number.isFinite(contentLength) ? contentLength : null; +}; + +const parseObjectKeyVideoId = (key: string) => + parseVideoIdFromObjectKey(key).pipe( + Option.map((id) => id as Video.VideoId), + Option.getOrNull, + ); + +const makeGoogleDriveAccess = ({ + repo, + integration, + config, +}: { + repo: StorageRepo; + integration: typeof Db.storageIntegrations.$inferSelect; + config: GoogleDriveIntegrationConfig; +}) => { + const integrationId = integration.id; + const ownerId = integration.ownerId; + const tokenStore = makeGoogleDriveTokenStore(repo, integration); + + const getObjectRecord = (key: string) => + mapStorageError(requireDriveObject(repo, integrationId, key)); + const recoverDriveFileId = ( + key: string, + previous: typeof Db.storageObjects.$inferSelect, + ) => + findGoogleDriveFileByObjectKey(config, key, tokenStore).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new StorageDomain.StorageError({ + cause: new Error(`Google Drive object not found: ${key}`), + }), + ), + onSome: (file) => { + const videoId = parseObjectKeyVideoId(key); + const contentType = file.mimeType ?? previous.contentType; + return mapStorageError( + repo.upsertObject({ + integrationId, + ownerId, + videoId, + objectKey: key, + providerObjectId: file.id, + uploadStatus: "complete", + contentType, + contentLength: + parseGoogleDriveContentLength(file) ?? + previous.contentLength ?? + null, + metadata: { + ...(previous.metadata ?? {}), + videoId: videoId ?? previous.metadata?.videoId, + fileName: file.name ?? previous.metadata?.fileName, + contentType: file.mimeType ?? previous.metadata?.contentType, + }, + }), + ).pipe(Effect.as(file.id)); + }, + }), + ), + ); + const withRecoveredDriveFile = ( + key: string, + object: typeof Db.storageObjects.$inferSelect, + read: (fileId: string) => Effect.Effect, + ) => + read(object.providerObjectId).pipe( + Effect.catchTag("StorageError", () => + recoverDriveFileId(key, object).pipe(Effect.flatMap(read)), + ), + ); + + return { + provider: "googleDrive" as const, + bucketName: "google-drive", + isPathStyle: false, + getSignedObjectUrl: (key: string) => createDriveObjectUrl(key), + getInternalSignedObjectUrl: (key: string) => + createDriveObjectUrl(key, 7200), + getObject: (key: string) => + getObjectRecord(key).pipe( + Effect.flatMap((object) => + withRecoveredDriveFile(key, object, (fileId) => + getGoogleDriveObjectText(config, fileId, tokenStore), + ), + ), + Effect.map(Option.some), + Effect.catchTag("StorageError", () => Effect.succeed(Option.none())), + ), + listObjects: (input: { + prefix?: string; + maxKeys?: number; + continuationToken?: string; + }) => + mapStorageError( + repo.listObjectsByPrefix(integrationId, input.prefix, input.maxKeys), + ).pipe( + Effect.map((objects) => ({ + Contents: objects + .filter( + (object) => + object.contentType !== GOOGLE_DRIVE_FOLDER_MIME_TYPE && + !object.objectKey.startsWith(".cap-folders/") && + !object.objectKey.startsWith(".cap-warnings/"), + ) + .map((object) => ({ + Key: object.objectKey, + Size: object.contentLength ?? undefined, + })), + KeyCount: objects.filter( + (object) => + object.contentType !== GOOGLE_DRIVE_FOLDER_MIME_TYPE && + !object.objectKey.startsWith(".cap-folders/") && + !object.objectKey.startsWith(".cap-warnings/"), + ).length, + IsTruncated: false, + NextContinuationToken: undefined, + })), + ), + headObject: (key: string) => + getObjectRecord(key).pipe( + Effect.flatMap((object) => + withRecoveredDriveFile(key, object, (fileId) => + getGoogleDriveFileMetadata(config, fileId, tokenStore), + ).pipe( + Effect.map((metadata) => ({ + ContentLength: metadata.size + ? Number(metadata.size) + : (object.contentLength ?? undefined), + ContentType: metadata.mimeType ?? object.contentType ?? undefined, + Metadata: object.metadata ?? undefined, + })), + ), + ), + ), + putObject: ( + key: string, + body: string | Uint8Array | ArrayBuffer, + fields?: { contentType?: string; contentLength?: number }, + ) => + Effect.gen(function* () { + const contentType = fields?.contentType ?? "application/octet-stream"; + const contentLength = + fields?.contentLength ?? + (typeof body === "string" + ? new TextEncoder().encode(body).byteLength + : body.byteLength); + const uploadUrl = yield* createGoogleDriveResumableUpload( + repo, + config, + { + integrationId, + ownerId, + videoId: parseVideoIdFromObjectKey(key).pipe( + Option.map((id) => id as Video.VideoId), + Option.getOrNull, + ), + key, + contentType, + contentLength, + }, + tokenStore, + ).pipe(mapStorageError); + const response = yield* Effect.tryPromise({ + try: () => + fetch(uploadUrl, { + method: "PUT", + headers: getGoogleDriveUploadHeaders(contentType, contentLength), + body, + }), + catch: (cause) => new StorageDomain.StorageError({ cause }), + }); + if (!response.ok) { + return yield* Effect.fail( + new StorageDomain.StorageError({ + cause: new Error( + `Google Drive upload failed: ${response.status}`, + ), + }), + ); + } + yield* mapStorageError( + repo.markObjectComplete(integrationId, key, contentLength), + ); + }), + copyObject: ( + source: string, + key: string, + args?: Omit, + ) => + getObjectRecord(parseSourceKey(source)).pipe( + Effect.flatMap((sourceObject) => + copyGoogleDriveFile({ + repo, + config, + sourceFileId: sourceObject.providerObjectId, + input: { + integrationId, + ownerId, + videoId: parseVideoIdFromObjectKey(key).pipe( + Option.map((id) => id as Video.VideoId), + Option.getOrNull, + ), + key, + contentType: + (args?.ContentType as string | undefined) ?? + sourceObject.contentType ?? + "application/octet-stream", + }, + tokenStore, + }).pipe(mapStorageError), + ), + ), + deleteObject: (key: string) => + getObjectRecord(key).pipe( + Effect.flatMap((object) => + deleteGoogleDriveFile( + config, + object.providerObjectId, + tokenStore, + ).pipe( + Effect.catchAll(() => Effect.void), + Effect.flatMap(() => + mapStorageError(repo.deleteObjectByKey(integrationId, key)), + ), + ), + ), + Effect.catchAll(() => Effect.void), + ), + deleteObjects: (objects: Array<{ Key?: string }>) => + Effect.forEach( + objects, + (object) => + object.Key + ? getObjectRecord(object.Key).pipe( + Effect.flatMap((record) => + deleteGoogleDriveFile( + config, + record.providerObjectId, + tokenStore, + ).pipe( + Effect.catchAll(() => Effect.void), + Effect.flatMap(() => + mapStorageError( + repo.deleteObjectByKey( + integrationId, + object.Key as string, + ), + ), + ), + ), + ), + Effect.catchAll(() => Effect.void), + ) + : Effect.void, + { concurrency: 3 }, + ), + getPresignedPutUrl: ( + key: string, + args?: Omit, + ) => + createGoogleDriveResumableUpload( + repo, + config, + { + integrationId, + ownerId, + videoId: parseVideoIdFromObjectKey(key).pipe( + Option.map((id) => id as Video.VideoId), + Option.getOrNull, + ), + key, + contentType: args?.ContentType ?? "application/octet-stream", + contentLength: args?.ContentLength, + }, + tokenStore, + ).pipe(mapStorageError), + getInternalPresignedPutUrl: ( + key: string, + args?: Omit, + ) => + createGoogleDriveResumableUpload( + repo, + config, + { + integrationId, + ownerId, + videoId: parseVideoIdFromObjectKey(key).pipe( + Option.map((id) => id as Video.VideoId), + Option.getOrNull, + ), + key, + contentType: args?.ContentType ?? "application/octet-stream", + contentLength: args?.ContentLength, + }, + tokenStore, + ).pipe(mapStorageError), + getPresignedPostUrl: (key: string) => + Effect.fail( + new StorageDomain.StorageError({ + cause: new Error( + `Google Drive does not support POST uploads: ${key}`, + ), + }), + ), + multipart: { + create: ( + key: string, + args?: Omit, + ) => + createGoogleDriveResumableUpload( + repo, + config, + { + integrationId, + ownerId, + videoId: parseVideoIdFromObjectKey(key).pipe( + Option.map((id) => id as Video.VideoId), + Option.getOrNull, + ), + key, + contentType: args?.ContentType ?? "application/octet-stream", + }, + tokenStore, + ).pipe( + mapStorageError, + Effect.map((UploadId) => ({ UploadId })), + ), + getPresignedUploadPartUrl: ( + _key: string, + uploadId: string, + _partNumber: number, + _args?: Omit< + S3.UploadPartCommandInput, + "Key" | "Bucket" | "PartNumber" | "UploadId" + >, + ) => Effect.succeed(uploadId), + complete: ( + key: string, + _uploadId?: string, + args?: Omit< + S3.CompleteMultipartUploadCommandInput, + "Key" | "Bucket" | "UploadId" + >, + ) => + getObjectRecord(key).pipe( + Effect.flatMap(() => + mapStorageError( + repo.markObjectComplete(integrationId, key, args?.MpuObjectSize), + ), + ), + Effect.flatMap(() => createDriveObjectUrl(key)), + Effect.map((Location) => ({ Location })), + ), + abort: ( + key: string, + _uploadId?: string, + _args?: Omit< + S3.AbortMultipartUploadCommandInput, + "Key" | "Bucket" | "UploadId" + >, + ) => + mapStorageError(repo.deleteObjectByKey(integrationId, key)).pipe( + Effect.as({}), + ), + }, + createUploadTarget: (key: string, input: UploadTargetInput) => + createGoogleDriveResumableUpload( + repo, + config, + { + integrationId, + ownerId, + videoId: parseVideoIdFromObjectKey(key).pipe( + Option.map((id) => id as Video.VideoId), + Option.getOrNull, + ), + key, + contentType: input.contentType, + contentLength: input.contentLength, + }, + tokenStore, + ).pipe( + mapStorageError, + Effect.map((url) => toDriveUploadTarget(url, input.contentType)), + ), + getObjectResponse: (key: string, range?: string | null) => + getObjectRecord(key).pipe( + Effect.flatMap((object) => + withRecoveredDriveFile(key, object, (fileId) => + getGoogleDriveObjectResponse(config, fileId, range, tokenStore), + ), + ), + ), + }; +}; + +export class Storage extends Effect.Service()("Storage", { + effect: Effect.gen(function* () { + const repo = yield* StorageRepo; + const s3Buckets = yield* S3Buckets; + + const getS3WritableAccessForUser = Effect.fn( + "Storage.getS3WritableAccessForUser", + )(function* (userId: User.UserId) { + const [s3, customBucket] = yield* mapStorageError( + s3Buckets.getBucketAccessForUser(userId), + ); + return { + access: makeS3Access(s3), + bucketId: Option.map(customBucket, (bucket) => bucket.id), + storageIntegrationId: Option.none(), + }; + }); + + const getDriveAccess = Effect.fn("Storage.getDriveAccess")(function* ( + integrationId: StorageDomain.StorageIntegrationId, + ) { + const integration = yield* mapStorageError( + repo.getIntegrationById(integrationId), + ).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new StorageDomain.StorageError({ + cause: new Error("Storage integration not found"), + }), + ), + onSome: Effect.succeed, + }), + ), + ); + const config = yield* mapStorageError( + repo.getGoogleDriveConfig(integration), + ); + return makeGoogleDriveAccess({ repo, integration, config }); + }); + + const getWritableAccessForUser = Effect.fn( + "Storage.getWritableAccessForUser", + )(function* (userId: User.UserId) { + const activeIntegration = yield* mapStorageError( + repo.getActiveIntegrationForUser(userId), + ); + if (Option.isSome(activeIntegration)) { + const access = yield* getDriveAccess(activeIntegration.value.id); + return { + access, + bucketId: Option.none(), + storageIntegrationId: Option.some(activeIntegration.value.id), + }; + } + + return yield* getS3WritableAccessForUser(userId); + }); + + const getAccessForVideo = Effect.fn("Storage.getAccessForVideo")(function* ( + video: Video.Video, + ) { + if (Option.isSome(video.storageIntegrationId)) { + const access = yield* getDriveAccess(video.storageIntegrationId.value); + return [access, Option.none()] as const; + } + + const [s3, customBucket] = yield* mapStorageError( + s3Buckets.getBucketAccess(video.bucketId), + ); + return [makeS3Access(s3), customBucket] as const; + }); + + const createUploadTargetForUser = Effect.fn( + "Storage.createUploadTargetForUser", + )(function* (userId: User.UserId, key: string, input: UploadTargetInput) { + const writable = yield* getWritableAccessForUser(userId); + const upload = yield* writable.access.createUploadTarget(key, input); + return { ...writable, upload }; + }); + + const createUploadTargetForVideo = Effect.fn( + "Storage.createUploadTargetForVideo", + )(function* (video: Video.Video, key: string, input: UploadTargetInput) { + const [access] = yield* getAccessForVideo(video); + return yield* access.createUploadTarget(key, input); + }); + + return { + getS3WritableAccessForUser, + getWritableAccessForUser, + getAccessForVideo, + createUploadTargetForUser, + createUploadTargetForVideo, + }; + }), + dependencies: [StorageRepo.Default, S3Buckets.Default], +}) { + static getWritableAccessForUser = (userId: User.UserId) => + Effect.flatMap(Storage, (storage) => + storage.getWritableAccessForUser(userId), + ); + static getS3WritableAccessForUser = (userId: User.UserId) => + Effect.flatMap(Storage, (storage) => + storage.getS3WritableAccessForUser(userId), + ); + static getAccessForVideo = (video: Video.Video) => + Effect.flatMap(Storage, (storage) => storage.getAccessForVideo(video)); + static createUploadTargetForUser = ( + userId: User.UserId, + key: string, + input: UploadTargetInput, + ) => + Effect.flatMap(Storage, (storage) => + storage.createUploadTargetForUser(userId, key, input), + ); + static createUploadTargetForVideo = ( + video: Video.Video, + key: string, + input: UploadTargetInput, + ) => + Effect.flatMap(Storage, (storage) => + storage.createUploadTargetForVideo(video, key, input), + ); +} diff --git a/packages/web-backend/src/Videos/VideosRepo.ts b/packages/web-backend/src/Videos/VideosRepo.ts index 991114e716..17dec55ddf 100644 --- a/packages/web-backend/src/Videos/VideosRepo.ts +++ b/packages/web-backend/src/Videos/VideosRepo.ts @@ -34,6 +34,7 @@ export class VideosRepo extends Effect.Service()("VideosRepo", { Video.Video.decodeSync({ ...v, bucketId: v.bucket, + storageIntegrationId: v.storageIntegrationId, createdAt: v.createdAt.toISOString(), updatedAt: v.updatedAt.toISOString(), metadata: v.metadata as any, @@ -70,6 +71,9 @@ export class VideosRepo extends Effect.Service()("VideosRepo", { id, orgId: data.orgId, bucket: Option.getOrNull(data.bucketId ?? Option.none()), + storageIntegrationId: Option.getOrNull( + data.storageIntegrationId ?? Option.none(), + ), metadata: Option.getOrNull(data.metadata ?? Option.none()), transcriptionStatus: Option.getOrNull( data.transcriptionStatus ?? Option.none(), diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index 13246de6ae..8c6a9b169e 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -15,7 +15,10 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( "DatabaseError", () => new InternalError({ type: "database" }), ), - Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), + Effect.catchTag( + "StorageError", + () => new InternalError({ type: "unknown" }), + ), ), VideoDuplicate: (videoId) => @@ -24,7 +27,10 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( "DatabaseError", () => new InternalError({ type: "database" }), ), - Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), + Effect.catchTag( + "StorageError", + () => new InternalError({ type: "unknown" }), + ), ), GetUploadProgress: (videoId) => @@ -46,7 +52,10 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( "DatabaseError", () => new InternalError({ type: "database" }), ), - Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), + Effect.catchTag( + "StorageError", + () => new InternalError({ type: "unknown" }), + ), ), VideoUploadProgressUpdate: (input) => @@ -70,7 +79,10 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( "UnknownException", () => new InternalError({ type: "unknown" }), ), - Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), + Effect.catchTag( + "StorageError", + () => new InternalError({ type: "unknown" }), + ), ), VideosGetThumbnails: (videoIds) => @@ -82,8 +94,8 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( () => new InternalError({ type: "database" }), ), Effect.catchTag( - "S3Error", - () => new InternalError({ type: "s3" }), + "StorageError", + () => new InternalError({ type: "unknown" }), ), Effect.matchEffect({ onSuccess: (v) => Effect.succeed(Exit.succeed(v)), diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index 0e9ba3f9ca..3ee88bec91 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -7,7 +7,7 @@ import { Array, Effect, Exit, Option } from "effect"; import type { Schema } from "effect/Schema"; import { Database } from "../Database.ts"; -import { S3Buckets } from "../S3Buckets/index.ts"; +import { Storage as StorageService } from "../Storage/index.ts"; import { Tinybird } from "../Tinybird/index.ts"; import { VideosPolicy } from "./VideosPolicy.ts"; import type { CreateVideoInput as RepoCreateVideoInput } from "./VideosRepo.ts"; @@ -47,7 +47,7 @@ export class Videos extends Effect.Service()("Videos", { const db = yield* Database; const repo = yield* VideosRepo; const policy = yield* VideosPolicy; - const s3Buckets = yield* S3Buckets; + const storage = yield* StorageService; const tinybird = yield* Tinybird; const getByIdForViewing = (id: Video.VideoId) => @@ -212,7 +212,7 @@ export class Videos extends Effect.Service()("Videos", { return yield* Effect.fail(new Video.NotFoundError()); const [video] = maybeVideo.value; - const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId); + const [bucket] = yield* storage.getAccessForVideo(video); yield* repo .delete(video.id) @@ -247,7 +247,7 @@ export class Videos extends Effect.Service()("Videos", { return yield* Effect.fail(new Video.NotFoundError()); const [video] = maybeVideo.value; - const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId); + const [bucket] = yield* storage.getAccessForVideo(video); // Don't duplicate password or sharing data const newVideoId = yield* repo.create(video); @@ -377,15 +377,10 @@ export class Videos extends Effect.Service()("Videos", { if (user.activeOrganizationId !== input.orgId) return yield* Effect.fail(new Policy.PolicyDeniedError()); - const [customBucket] = yield* db.use((db) => - db - .select() - .from(Db.s3Buckets) - .where(Dz.eq(Db.s3Buckets.ownerId, user.id)), - ); - - const bucketId: RepoCreateVideoInput["bucketId"] = - Option.fromNullable(customBucket?.id); + const writable = yield* storage.getWritableAccessForUser(user.id); + const bucketId: RepoCreateVideoInput["bucketId"] = writable.bucketId; + const storageIntegrationId: RepoCreateVideoInput["storageIntegrationId"] = + writable.storageIntegrationId; const folderId: RepoCreateVideoInput["folderId"] = input.folderId ?? Option.none(); const width: RepoCreateVideoInput["width"] = Option.fromNullable( @@ -412,6 +407,7 @@ export class Videos extends Effect.Service()("Videos", { public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, source: { type: "webMP4" }, bucketId, + storageIntegrationId, folderId, width, height, @@ -430,9 +426,9 @@ export class Videos extends Effect.Service()("Videos", { ); const fileKey = `${user.id}/${videoId}/result.mp4`; - const [bucket] = yield* s3Buckets.getBucketAccess(bucketId); - const presignedPostData = yield* bucket.getPresignedPostUrl(fileKey, { - Fields: { + const upload = yield* writable.access.createUploadTarget(fileKey, { + contentType: "video/mp4", + fields: { "Content-Type": "video/mp4", "x-amz-meta-userid": user.id, "x-amz-meta-duration": input.durationSeconds @@ -442,7 +438,6 @@ export class Videos extends Effect.Service()("Videos", { "x-amz-meta-videocodec": input.videoCodec ?? "", "x-amz-meta-audiocodec": input.audioCodec ?? "", }, - Expires: 1800, }); const shareUrl = `${serverEnv().WEB_URL}/s/${videoId}`; @@ -463,10 +458,7 @@ export class Videos extends Effect.Service()("Videos", { return { id: videoId, shareUrl, - upload: { - url: presignedPostData.url, - fields: presignedPostData.fields, - }, + upload, }; }, ), @@ -483,7 +475,7 @@ export class Videos extends Effect.Service()("Videos", { return yield* Effect.fail(new Video.NotFoundError()); const [video] = maybeVideo.value; - const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId); + const [bucket] = yield* storage.getAccessForVideo(video); const src = Video.Video.getSource(video); if (src instanceof Video.Mp4Source && video.source.type === "webMP4") { @@ -542,7 +534,7 @@ export class Videos extends Effect.Service()("Videos", { if (Option.isNone(maybeVideo)) return Option.none(); const [video] = maybeVideo.value; - const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId); + const [bucket] = yield* storage.getAccessForVideo(video); const listResponse = yield* bucket.listObjects({ prefix: `${video.ownerId}/${video.id}/`, }); @@ -572,7 +564,7 @@ export class Videos extends Effect.Service()("Videos", { VideosPolicy.Default, VideosRepo.Default, Database.Default, - S3Buckets.Default, + StorageService.Default, Tinybird.Default, ], }) {} diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index 92c6282c50..81bb1e6ba9 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -11,6 +11,18 @@ export * from "./Rpcs.ts"; export { S3Buckets } from "./S3Buckets/index.ts"; export { Spaces } from "./Spaces/index.ts"; export { SpacesPolicy } from "./Spaces/SpacesPolicy.ts"; +export * from "./Storage/GoogleDrive.ts"; +export { Storage } from "./Storage/index.ts"; +export { + createStorageObjectToken, + verifyStorageObjectToken, +} from "./Storage/SignedObject.ts"; +export { + type GoogleDriveIntegrationConfig, + type GoogleDriveStorageQuota, + type GoogleDriveStorageQuotaCache, + StorageRepo, +} from "./Storage/StorageRepo.ts"; export { Tinybird } from "./Tinybird/index.ts"; export { Users } from "./Users/index.ts"; export { Videos } from "./Videos/index.ts"; diff --git a/packages/web-domain/src/Storage.ts b/packages/web-domain/src/Storage.ts new file mode 100644 index 0000000000..21ebcaae74 --- /dev/null +++ b/packages/web-domain/src/Storage.ts @@ -0,0 +1,77 @@ +import { Schema } from "effect"; +import { UserId } from "./User.ts"; +import type { VideoId } from "./Video.ts"; + +export const StorageIntegrationId = Schema.String.pipe( + Schema.brand("StorageIntegrationId"), +); +export type StorageIntegrationId = typeof StorageIntegrationId.Type; + +export const StorageObjectId = Schema.String.pipe( + Schema.brand("StorageObjectId"), +); +export type StorageObjectId = typeof StorageObjectId.Type; + +export const StorageProvider = Schema.Literal("googleDrive"); +export type StorageProvider = typeof StorageProvider.Type; + +export const StorageIntegrationStatus = Schema.Literal( + "active", + "error", + "disconnected", +); +export type StorageIntegrationStatus = typeof StorageIntegrationStatus.Type; + +export class StorageIntegration extends Schema.Class( + "StorageIntegration", +)({ + id: StorageIntegrationId, + ownerId: UserId, + provider: StorageProvider, + displayName: Schema.String, + status: StorageIntegrationStatus, + active: Schema.Boolean, +}) {} + +export const S3PostUploadTarget = Schema.Struct({ + type: Schema.Literal("s3Post"), + url: Schema.String, + fields: Schema.Record({ key: Schema.String, value: Schema.String }), +}); + +export const PutUploadTarget = Schema.Struct({ + type: Schema.Literal("put"), + url: Schema.String, + headers: Schema.Record({ key: Schema.String, value: Schema.String }), +}); + +export const DriveResumableUploadTarget = Schema.Struct({ + type: Schema.Literal("driveResumable"), + url: Schema.String, + headers: Schema.Record({ key: Schema.String, value: Schema.String }), +}); + +export const UploadTarget = Schema.Union( + S3PostUploadTarget, + PutUploadTarget, + DriveResumableUploadTarget, +); +export type UploadTarget = typeof UploadTarget.Type; + +export class StorageError extends Schema.TaggedError()( + "StorageError", + { + cause: Schema.Unknown, + }, +) {} + +export type StorageObjectMetadata = { + videoId?: VideoId; + fileName?: string; + contentType?: string; + duration?: string; + bandwidth?: string; + resolution?: string; + videocodec?: string; + audiocodec?: string; +}; diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 9800867a0b..46ae238508 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -7,6 +7,7 @@ import { FolderId } from "./Folder.ts"; import { OrganisationId } from "./Organisation.ts"; import { PolicyDeniedError } from "./Policy.ts"; import { S3BucketId } from "./S3Bucket.ts"; +import { StorageIntegrationId, UploadTarget } from "./Storage.ts"; import { UserId } from "./User.ts"; export const VideoId = Schema.String.pipe(Schema.brand("VideoId")); @@ -32,6 +33,7 @@ export class Video extends Schema.Class