From f4722a0bf7cf833f799c8f915c265f04304e61ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 03:55:18 +0000 Subject: [PATCH 01/39] feat: add Loom import job tables Co-authored-by: Richie McIlroy --- .../0050_productive_christian_walker.sql | 42 + .../migrations/meta/0050_snapshot.json | 5734 +++++++++++++++++ .../database/migrations/meta/_journal.json | 717 ++- packages/database/schema.ts | 82 + 4 files changed, 6220 insertions(+), 355 deletions(-) create mode 100644 packages/database/migrations/0050_productive_christian_walker.sql create mode 100644 packages/database/migrations/meta/0050_snapshot.json diff --git a/packages/database/migrations/0050_productive_christian_walker.sql b/packages/database/migrations/0050_productive_christian_walker.sql new file mode 100644 index 00000000000..4d37995be58 --- /dev/null +++ b/packages/database/migrations/0050_productive_christian_walker.sql @@ -0,0 +1,42 @@ +CREATE TABLE `loom_import_job_items` ( + `id` varchar(15) NOT NULL, + `job_id` varchar(15) NOT NULL, + `csv_row` int NOT NULL, + `loom_url` varchar(1024) NOT NULL, + `loom_video_id` varchar(64), + `owner_email` varchar(255), + `space_name` varchar(255), + `status` varchar(16) NOT NULL DEFAULT 'pending', + `owner_id` varchar(15), + `space_id` varchar(15), + `video_id` varchar(15), + `title` varchar(255), + `loom_created_at` datetime(3), + `duration_seconds` float, + `width` int, + `height` int, + `thumbnail_url` varchar(1024), + `error` varchar(512), + `updated_at` datetime(3) NOT NULL, + CONSTRAINT `loom_import_job_items_id` PRIMARY KEY(`id`), + CONSTRAINT `job_row_idx` UNIQUE(`job_id`,`csv_row`) +); +--> statement-breakpoint +CREATE TABLE `loom_import_jobs` ( + `id` varchar(15) NOT NULL, + `org_id` varchar(15) NOT NULL, + `created_by_id` varchar(15) NOT NULL, + `file_name` varchar(255) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'checking', + `total_count` int NOT NULL, + `created_at` datetime(3) NOT NULL, + `updated_at` datetime(3) NOT NULL, + `started_at` datetime(3), + `completed_at` datetime(3), + CONSTRAINT `loom_import_jobs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE INDEX `job_status_row_idx` ON `loom_import_job_items` (`job_id`,`status`,`csv_row`);--> statement-breakpoint +CREATE INDEX `video_id_idx` ON `loom_import_job_items` (`video_id`);--> statement-breakpoint +CREATE INDEX `org_creator_created_idx` ON `loom_import_jobs` (`org_id`,`created_by_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `status_updated_idx` ON `loom_import_jobs` (`status`,`updated_at`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0050_snapshot.json b/packages/database/migrations/meta/0050_snapshot.json new file mode 100644 index 00000000000..76994f3a1b6 --- /dev/null +++ b/packages/database/migrations/meta/0050_snapshot.json @@ -0,0 +1,5734 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "b3eb8041-9563-45df-99f3-649946dfd1e6", + "prevId": "7db9e8b2-ba9e-416d-a243-4b79b5d4ec5d", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_in": { + "name": "refresh_token_expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "tempColumn": { + "name": "tempColumn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "provider_account_id_idx": { + "name": "provider_account_id_idx", + "columns": [ + "providerAccountId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accounts_id": { + "name": "accounts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_authorization_codes": { + "name": "agent_api_authorization_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": [ + "expiresAt" + ], + "isUnique": false + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": [ + "userId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_authorization_codes_id": { + "name": "agent_api_authorization_codes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_idempotency": { + "name": "agent_api_idempotency", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestHash": { + "name": "requestHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "statusCode": { + "name": "statusCode", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_operation_key_idx": { + "name": "user_operation_key_idx", + "columns": [ + "userId", + "operation", + "keyHash" + ], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_idempotency_id": { + "name": "agent_api_idempotency_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_keys": { + "name": "agent_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Cap CLI'" + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "token_hash_idx": { + "name": "token_hash_idx", + "columns": [ + "tokenHash" + ], + "isUnique": true + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": [ + "userId", + "createdAt" + ], + "isUnique": false + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_keys_id": { + "name": "agent_api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_operations": { + "name": "agent_api_operations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resourceId": { + "name": "resourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resultResourceId": { + "name": "resultResourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": [ + "userId", + "createdAt" + ], + "isUnique": false + }, + "state_updated_at_idx": { + "name": "state_updated_at_idx", + "columns": [ + "state", + "updatedAt" + ], + "isUnique": false + }, + "resource_id_idx": { + "name": "resource_id_idx", + "columns": [ + "resourceId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_operations_id": { + "name": "agent_api_operations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth_api_keys": { + "name": "auth_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_id_created_at_idx": { + "name": "user_id_created_at_idx", + "columns": [ + "userId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_api_keys_id": { + "name": "auth_api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "parentCommentId": { + "name": "parentCommentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaKey": { + "name": "mediaKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaDuration": { + "name": "mediaDuration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaMeta": { + "name": "mediaMeta", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_type_created_idx": { + "name": "video_type_created_idx", + "columns": [ + "videoId", + "type", + "createdAt", + "id" + ], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": [ + "authorId" + ], + "isUnique": false + }, + "parent_comment_id_idx": { + "name": "parent_comment_id_idx", + "columns": [ + "parentCommentId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_api_keys": { + "name": "developer_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyType": { + "name": "keyType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyPrefix": { + "name": "keyPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedKey": { + "name": "encryptedKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "key_hash_idx": { + "name": "key_hash_idx", + "columns": [ + "keyHash" + ], + "isUnique": true + }, + "app_key_type_idx": { + "name": "app_key_type_idx", + "columns": [ + "appId", + "keyType" + ], + "isUnique": false + } + }, + "foreignKeys": { + "developer_api_keys_appId_developer_apps_id_fk": { + "name": "developer_api_keys_appId_developer_apps_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "developer_apps", + "columnsFrom": [ + "appId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_keys_id": { + "name": "developer_api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_app_domains": { + "name": "developer_app_domains", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_app_domains_appId_developer_apps_id_fk": { + "name": "developer_app_domains_appId_developer_apps_id_fk", + "tableFrom": "developer_app_domains", + "tableTo": "developer_apps", + "columnsFrom": [ + "appId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_app_domains_id": { + "name": "developer_app_domains_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "app_domain_unique": { + "name": "app_domain_unique", + "columns": [ + "appId", + "domain" + ] + } + }, + "checkConstraint": {} + }, + "developer_apps": { + "name": "developer_apps", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_deleted_idx": { + "name": "owner_deleted_idx", + "columns": [ + "ownerId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "developer_apps_id": { + "name": "developer_apps_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_accounts": { + "name": "developer_credit_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceMicroCredits": { + "name": "balanceMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePaymentMethodId": { + "name": "stripePaymentMethodId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoTopUpEnabled": { + "name": "autoTopUpEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autoTopUpThresholdMicroCredits": { + "name": "autoTopUpThresholdMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoTopUpAmountCents": { + "name": "autoTopUpAmountCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_id_unique": { + "name": "app_id_unique", + "columns": [ + "appId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "developer_credit_accounts_appId_developer_apps_id_fk": { + "name": "developer_credit_accounts_appId_developer_apps_id_fk", + "tableFrom": "developer_credit_accounts", + "tableTo": "developer_apps", + "columnsFrom": [ + "appId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_accounts_id": { + "name": "developer_credit_accounts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_transactions": { + "name": "developer_credit_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amountMicroCredits": { + "name": "amountMicroCredits", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceAfterMicroCredits": { + "name": "balanceAfterMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceType": { + "name": "referenceType", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "account_type_created_idx": { + "name": "account_type_created_idx", + "columns": [ + "accountId", + "type", + "createdAt" + ], + "isUnique": false + }, + "account_ref_dedup_idx": { + "name": "account_ref_dedup_idx", + "columns": [ + "accountId", + "referenceId", + "referenceType" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dev_credit_txn_account_fk": { + "name": "dev_credit_txn_account_fk", + "tableFrom": "developer_credit_transactions", + "tableTo": "developer_credit_accounts", + "columnsFrom": [ + "accountId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_transactions_id": { + "name": "developer_credit_transactions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_daily_storage_snapshots": { + "name": "developer_daily_storage_snapshots", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshotDate": { + "name": "snapshotDate", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalDurationMinutes": { + "name": "totalDurationMinutes", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "videoCount": { + "name": "videoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "microCreditsCharged": { + "name": "microCreditsCharged", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_daily_storage_snapshots_appId_developer_apps_id_fk": { + "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk", + "tableFrom": "developer_daily_storage_snapshots", + "tableTo": "developer_apps", + "columnsFrom": [ + "appId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_daily_storage_snapshots_id": { + "name": "developer_daily_storage_snapshots_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "app_date_unique": { + "name": "app_date_unique", + "columns": [ + "appId", + "snapshotDate" + ] + } + }, + "checkConstraint": {} + }, + "developer_videos": { + "name": "developer_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_created_idx": { + "name": "app_created_idx", + "columns": [ + "appId", + "createdAt" + ], + "isUnique": false + }, + "app_user_idx": { + "name": "app_user_idx", + "columns": [ + "appId", + "externalUserId" + ], + "isUnique": false + }, + "app_deleted_idx": { + "name": "app_deleted_idx", + "columns": [ + "appId", + "deletedAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "developer_videos_appId_developer_apps_id_fk": { + "name": "developer_videos_appId_developer_apps_id_fk", + "tableFrom": "developer_videos", + "tableTo": "developer_apps", + "columnsFrom": [ + "appId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_videos_id": { + "name": "developer_videos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": [ + "organizationId" + ], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": [ + "createdById" + ], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parentId" + ], + "isUnique": false + }, + "space_id_idx": { + "name": "space_id_idx", + "columns": [ + "spaceId" + ], + "isUnique": false + }, + "public_parent_id_idx": { + "name": "public_parent_id_idx", + "columns": [ + "public", + "parentId" + ], + "isUnique": false + }, + "public_space_parent_id_idx": { + "name": "public_space_parent_id_idx", + "columns": [ + "public", + "spaceId", + "parentId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "folders_id": { + "name": "folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "imported_videos": { + "name": "imported_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "id_idx": { + "name": "id_idx", + "columns": [ + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": [ + "orgId", + "source", + "source_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_installations": { + "name": "integration_installations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installedByUserId": { + "name": "installedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedCredentials": { + "name": "encryptedCredentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "provider_external_id_idx": { + "name": "provider_external_id_idx", + "columns": [ + "provider", + "externalId" + ], + "isUnique": true + }, + "organization_provider_display_name_idx": { + "name": "organization_provider_display_name_idx", + "columns": [ + "organizationId", + "provider", + "displayName" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_installations_id": { + "name": "integration_installations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_import_job_items": { + "name": "loom_import_job_items", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "csv_row": { + "name": "csv_row", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "loom_url": { + "name": "loom_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "loom_video_id": { + "name": "loom_video_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_email": { + "name": "owner_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "space_name": { + "name": "space_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loom_created_at": { + "name": "loom_created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "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 + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "job_row_idx": { + "name": "job_row_idx", + "columns": [ + "job_id", + "csv_row" + ], + "isUnique": true + }, + "job_status_row_idx": { + "name": "job_status_row_idx", + "columns": [ + "job_id", + "status", + "csv_row" + ], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": [ + "video_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_import_job_items_id": { + "name": "loom_import_job_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_import_jobs": { + "name": "loom_import_jobs", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'checking'" + }, + "total_count": { + "name": "total_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "org_creator_created_idx": { + "name": "org_creator_created_idx", + "columns": [ + "org_id", + "created_by_id", + "created_at" + ], + "isUnique": false + }, + "status_updated_idx": { + "name": "status_updated_idx", + "columns": [ + "status", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_import_jobs_id": { + "name": "loom_import_jobs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loops_sync_jobs": { + "name": "loops_sync_jobs", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "nextAttemptAt": { + "name": "nextAttemptAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "leaseToken": { + "name": "leaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseUntil": { + "name": "leaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failures": { + "name": "failures", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lastError": { + "name": "lastError", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profileHash": { + "name": "profileHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teammateJoinedAt": { + "name": "teammateJoinedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedEmail": { + "name": "syncedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSyncedAt": { + "name": "lastSyncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "loops_sync_due_idx": { + "name": "loops_sync_due_idx", + "columns": [ + "nextAttemptAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loops_sync_jobs_userId": { + "name": "loops_sync_jobs_userId", + "columns": [ + "userId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientName": { + "name": "clientName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUris": { + "name": "redirectUris", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activatedAt": { + "name": "activatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "client_id_idx": { + "name": "client_id_idx", + "columns": [ + "clientId" + ], + "isUnique": true + }, + "inactive_created_at_idx": { + "name": "inactive_created_at_idx", + "columns": [ + "activatedAt", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_clients_id": { + "name": "mcp_oauth_clients_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_codes": { + "name": "mcp_oauth_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": [ + "codeHash" + ], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_codes_id": { + "name": "mcp_oauth_codes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_registration_quotas": { + "name": "mcp_oauth_registration_quotas", + "columns": { + "windowId": { + "name": "windowId", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registrations": { + "name": "registrations", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "expires_at_idx": { + "name": "expires_at_idx", + "columns": [ + "expiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_registration_quotas_windowId": { + "name": "mcp_oauth_registration_quotas_windowId", + "columns": [ + "windowId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_tokens": { + "name": "mcp_oauth_tokens", + "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 + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessHash": { + "name": "accessHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshHash": { + "name": "refreshHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessExpiresAt": { + "name": "accessExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshExpiresAt": { + "name": "refreshExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "access_hash_idx": { + "name": "access_hash_idx", + "columns": [ + "accessHash" + ], + "isUnique": true + }, + "refresh_hash_idx": { + "name": "refresh_hash_idx", + "columns": [ + "refreshHash" + ], + "isUnique": true + }, + "family_id_idx": { + "name": "family_id_idx", + "columns": [ + "familyId" + ], + "isUnique": false + }, + "client_active_idx": { + "name": "client_active_idx", + "columns": [ + "clientId", + "revokedAt", + "refreshExpiresAt" + ], + "isUnique": false + }, + "refresh_expires_at_idx": { + "name": "refresh_expires_at_idx", + "columns": [ + "refreshExpiresAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_tokens_id": { + "name": "mcp_oauth_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media_processing_budgets": { + "name": "media_processing_budgets", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reserved_bytes": { + "name": "reserved_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "limit_bytes": { + "name": "limit_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_budget_expiry_idx": { + "name": "media_budget_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "media_processing_budgets_id": { + "name": "media_processing_budgets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_conversations": { + "name": "messenger_conversations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverByUserId": { + "name": "takeoverByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverAt": { + "name": "takeoverAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_last_message_idx": { + "name": "user_last_message_idx", + "columns": [ + "userId", + "lastMessageAt" + ], + "isUnique": false + }, + "anonymous_last_message_idx": { + "name": "anonymous_last_message_idx", + "columns": [ + "anonymousId", + "lastMessageAt" + ], + "isUnique": false + }, + "mode_last_message_idx": { + "name": "mode_last_message_idx", + "columns": [ + "mode", + "lastMessageAt" + ], + "isUnique": false + }, + "updated_at_idx": { + "name": "updated_at_idx", + "columns": [ + "updatedAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "messenger_conversations_id": { + "name": "messenger_conversations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_messages": { + "name": "messenger_messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "conversation_created_at_idx": { + "name": "conversation_created_at_idx", + "columns": [ + "conversationId", + "createdAt" + ], + "isUnique": false + }, + "role_created_at_idx": { + "name": "role_created_at_idx", + "columns": [ + "role", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messenger_messages_conversationId_messenger_conversations_id_fk": { + "name": "messenger_messages_conversationId_messenger_conversations_id_fk", + "tableFrom": "messenger_messages", + "tableTo": "messenger_conversations", + "columnsFrom": [ + "conversationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_messages_id": { + "name": "messenger_messages_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_support_emails": { + "name": "messenger_support_emails", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userEmail": { + "name": "userEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "support_email_user_created_at_idx": { + "name": "support_email_user_created_at_idx", + "columns": [ + "userId", + "createdAt" + ], + "isUnique": false + }, + "support_email_conversation_created_at_idx": { + "name": "support_email_conversation_created_at_idx", + "columns": [ + "conversationId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "support_email_conversation_fk": { + "name": "support_email_conversation_fk", + "tableFrom": "messenger_support_emails", + "tableTo": "messenger_conversations", + "columnsFrom": [ + "conversationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_support_emails_id": { + "name": "messenger_support_emails_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientId": { + "name": "recipientId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": [ + "orgId" + ], + "isUnique": false + }, + "type_idx": { + "name": "type_idx", + "columns": [ + "type" + ], + "isUnique": false + }, + "read_at_idx": { + "name": "read_at_idx", + "columns": [ + "readAt" + ], + "isUnique": false + }, + "created_at_idx": { + "name": "created_at_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + }, + "recipient_read_idx": { + "name": "recipient_read_idx", + "columns": [ + "recipientId", + "readAt" + ], + "isUnique": false + }, + "recipient_created_idx": { + "name": "recipient_created_idx", + "columns": [ + "recipientId", + "createdAt" + ], + "isUnique": false + }, + "dedup_key_idx": { + "name": "dedup_key_idx", + "columns": [ + "dedupKey" + ], + "isUnique": true + }, + "type_recipient_created_idx": { + "name": "type_recipient_created_idx", + "columns": [ + "type", + "recipientId", + "createdAt" + ], + "isUnique": false + }, + "type_recipient_video_created_idx": { + "name": "type_recipient_video_created_idx", + "columns": [ + "type", + "recipientId", + "videoId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": [ + "organizationId" + ], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": [ + "invitedEmail" + ], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": [ + "invitedByUserId" + ], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": [ + "organizationId" + ], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": [ + "userId", + "organizationId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_sso": { + "name": "organization_sso", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchasedByUserId": { + "name": "purchasedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "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 + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "paidThrough": { + "name": "paidThrough", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "checkoutAttemptId": { + "name": "checkoutAttemptId", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutCurrency": { + "name": "checkoutCurrency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutPriceId": { + "name": "checkoutPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutSessionId": { + "name": "checkoutSessionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutStartedAt": { + "name": "checkoutStartedAt", + "type": "datetime", + "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": { + "sso_stripe_subscription_id_idx": { + "name": "sso_stripe_subscription_id_idx", + "columns": [ + "stripeSubscriptionId" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_sso_organizationId": { + "name": "organization_sso_organizationId", + "columns": [ + "organizationId" + ] + } + }, + "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 + }, + "defaultVideoVisibility": { + "name": "defaultVideoVisibility", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shareableLinkIconUrl": { + "name": "shareableLinkIconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": [ + "ownerId", + "tombstoneAt" + ], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": [ + "customDomain" + ], + "isUnique": false + }, + "workos_organization_id_idx": { + "name": "workos_organization_id_idx", + "columns": [ + "workosOrganizationId" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketName": { + "name": "bucketName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessKeyId": { + "name": "accessKeyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aws')" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_organization_idx": { + "name": "owner_organization_idx", + "columns": [ + "ownerId", + "organizationId" + ], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": [ + "organizationId" + ], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": [ + "organizationId", + "active", + "updatedAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "s3_buckets_id": { + "name": "s3_buckets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + "sessionToken" + ], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_videos": { + "name": "shared_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedByUserId": { + "name": "sharedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": [ + "folderId" + ], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": [ + "organizationId" + ], + "isUnique": false + }, + "shared_by_user_id_idx": { + "name": "shared_by_user_id_idx", + "columns": [ + "sharedByUserId" + ], + "isUnique": false + }, + "video_id_organization_id_idx": { + "name": "video_id_organization_id_idx", + "columns": [ + "videoId", + "organizationId" + ], + "isUnique": false + }, + "video_id_folder_id_idx": { + "name": "video_id_folder_id_idx", + "columns": [ + "videoId", + "folderId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "shared_videos_id": { + "name": "shared_videos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "signed_baas": { + "name": "signed_baas", + "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 + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entityName": { + "name": "entityName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityType": { + "name": "entityType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityAddress": { + "name": "entityAddress", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerName": { + "name": "signerName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerTitle": { + "name": "signerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "noticesEmail": { + "name": "noticesEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signatureData": { + "name": "signatureData", + "type": "longtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signedAt": { + "name": "signedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailSentAt": { + "name": "emailSentAt", + "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": { + "signed_baa_organization_id_idx": { + "name": "signed_baa_organization_id_idx", + "columns": [ + "organizationId" + ], + "isUnique": true + }, + "signed_baa_stripe_subscription_idx": { + "name": "signed_baa_stripe_subscription_idx", + "columns": [ + "stripeSubscriptionId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "signed_baas_id": { + "name": "signed_baas_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_members_id": { + "name": "space_members_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "space_id_user_id_unique": { + "name": "space_id_user_id_unique", + "columns": [ + "spaceId", + "userId" + ] + } + }, + "checkConstraint": {} + }, + "space_videos": { + "name": "space_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedById": { + "name": "addedById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": [ + "folderId" + ], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": [ + "videoId" + ], + "isUnique": false + }, + "added_by_id_idx": { + "name": "added_by_id_idx", + "columns": [ + "addedById" + ], + "isUnique": false + }, + "space_id_video_id_idx": { + "name": "space_id_video_id_idx", + "columns": [ + "spaceId", + "videoId" + ], + "isUnique": false + }, + "space_id_folder_id_idx": { + "name": "space_id_folder_id_idx", + "columns": [ + "spaceId", + "folderId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_videos_id": { + "name": "space_videos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary": { + "name": "primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "privacy": { + "name": "privacy", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Private'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": [ + "organizationId" + ], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": [ + "createdById" + ], + "isUnique": false + }, + "public_organization_id_idx": { + "name": "public_organization_id_idx", + "columns": [ + "public", + "organizationId" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spaces_id": { + "name": "spaces_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_integrations": { + "name": "storage_integrations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "encryptedConfig": { + "name": "encryptedConfig", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "googleDriveAccessToken": { + "name": "googleDriveAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveAccessTokenExpiresAt": { + "name": "googleDriveAccessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseId": { + "name": "googleDriveTokenRefreshLeaseId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseExpiresAt": { + "name": "googleDriveTokenRefreshLeaseExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveStorageQuotaCache": { + "name": "googleDriveStorageQuotaCache", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_provider_idx": { + "name": "owner_provider_idx", + "columns": [ + "ownerId", + "provider" + ], + "isUnique": false + }, + "owner_active_idx": { + "name": "owner_active_idx", + "columns": [ + "ownerId", + "active" + ], + "isUnique": false + }, + "organization_provider_idx": { + "name": "organization_provider_idx", + "columns": [ + "organizationId", + "provider" + ], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": [ + "organizationId", + "active", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "storage_integrations_id": { + "name": "storage_integrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_objects": { + "name": "storage_objects", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrationId": { + "name": "integrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectKey": { + "name": "objectKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "objectKeyHash": { + "name": "objectKeyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerObjectId": { + "name": "providerObjectId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadSessionUrl": { + "name": "uploadSessionUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadStatus": { + "name": "uploadStatus", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "contentType": { + "name": "contentType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentLength": { + "name": "contentLength", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "integration_key_hash_idx": { + "name": "integration_key_hash_idx", + "columns": [ + "integrationId", + "objectKeyHash" + ], + "isUnique": true + }, + "integration_status_idx": { + "name": "integration_status_idx", + "columns": [ + "integrationId", + "uploadStatus" + ], + "isUnique": false + }, + "integration_object_key_prefix_idx": { + "name": "integration_object_key_prefix_idx", + "columns": [ + "integrationId", + "`objectKey`(191)" + ], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": [ + "videoId" + ], + "isUnique": false + }, + "owner_id_idx": { + "name": "owner_id_idx", + "columns": [ + "ownerId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "storage_objects_integrationId_storage_integrations_id_fk": { + "name": "storage_objects_integrationId_storage_integrations_id_fk", + "tableFrom": "storage_objects", + "tableTo": "storage_integrations", + "columnsFrom": [ + "integrationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "storage_objects_id": { + "name": "storage_objects_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authSessionVersion": { + "name": "authSessionVersion", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "marketingOrigin": { + "name": "marketingOrigin", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": [ + "identifier" + ] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": [ + "token" + ] + } + }, + "checkConstraint": {} + }, + "video_edits": { + "name": "video_edits", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceKey": { + "name": "sourceKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editSpec": { + "name": "editSpec", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "video_edits_videoId_videos_id_fk": { + "name": "video_edits_videoId_videos_id_fk", + "tableFrom": "video_edits", + "tableTo": "videos", + "columnsFrom": [ + "videoId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_edits_videoId": { + "name": "video_edits_videoId", + "columns": [ + "videoId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_processing_jobs": { + "name": "video_processing_jobs", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'committing'" + }, + "attempt_id": { + "name": "attempt_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_job_id": { + "name": "remote_job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification": { + "name": "verification", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "processing_state_retry_video_idx": { + "name": "processing_state_retry_video_idx", + "columns": [ + "state", + "next_retry_at", + "video_id" + ], + "isUnique": false + }, + "processing_state_lease_video_idx": { + "name": "processing_state_lease_video_idx", + "columns": [ + "state", + "lease_expires_at", + "video_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_processing_jobs_video_id": { + "name": "video_processing_jobs_video_id", + "columns": [ + "video_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "phase_updated_at_video_id_idx": { + "name": "phase_updated_at_video_id_idx", + "columns": [ + "phase", + "updated_at", + "video_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": [ + "video_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_viewer_grants": { + "name": "video_viewer_grants", + "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 + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_email_idx": { + "name": "video_email_idx", + "columns": [ + "videoId", + "email" + ], + "isUnique": true + } + }, + "foreignKeys": { + "video_viewer_grants_videoId_videos_id_fk": { + "name": "video_viewer_grants_videoId_videos_id_fk", + "tableFrom": "video_viewer_grants", + "tableTo": "videos", + "columnsFrom": [ + "videoId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_viewer_grants_id": { + "name": "video_viewer_grants_id", + "columns": [ + "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 + }, + "owner_updated_id_idx": { + "name": "owner_updated_id_idx", + "columns": [ + "ownerId", + "updatedAt", + "id" + ], + "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 + }, + "screenshot_transcription_created_idx": { + "name": "screenshot_transcription_created_idx", + "columns": [ + "isScreenshot", + "transcriptionStatus", + "createdAt" + ], + "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": { + "integration_object_key_prefix_idx": { + "columns": { + "`objectKey`(191)": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index d09298a78fb..b72ca9d21f3 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -1,356 +1,363 @@ { - "version": "5", - "dialect": "mysql", - "entries": [ - { - "idx": 0, - "version": "5", - "when": 1743020179593, - "tag": "0000_brown_sunfire", - "breakpoints": true - }, - { - "idx": 1, - "version": "5", - "when": 1749268354138, - "tag": "0001_white_young_avengers", - "breakpoints": true - }, - { - "idx": 2, - "version": "5", - "when": 1750935538683, - "tag": "0002_dusty_maginty", - "breakpoints": true - }, - { - "idx": 3, - "version": "5", - "when": 1761710697286, - "tag": "0003_thin_gressill", - "breakpoints": true - }, - { - "idx": 4, - "version": "5", - "when": 1761711378574, - "tag": "0004_video-org-id", - "breakpoints": true - }, - { - "idx": 5, - "version": "5", - "when": 1761711605408, - "tag": "0005_video-org-id-required", - "breakpoints": true - }, - { - "idx": 6, - "version": "5", - "when": 1762410865419, - "tag": "0006_hesitant_stone_men", - "breakpoints": true - }, - { - "idx": 7, - "version": "5", - "when": 1762428551824, - "tag": "0007_public_toxin", - "breakpoints": true - }, - { - "idx": 8, - "version": "5", - "when": 1762428905323, - "tag": "0008_fat_ender_wiggin", - "breakpoints": true - }, - { - "idx": 9, - "version": "5", - "when": 1768139432782, - "tag": "0009_easy_ulik", - "breakpoints": true - }, - { - "idx": 10, - "version": "5", - "when": 1738505600000, - "tag": "0010_video_uploads_bigint", - "breakpoints": true - }, - { - "idx": 11, - "version": "5", - "when": 1771325011010, - "tag": "0011_public_inhumans", - "breakpoints": true - }, - { - "idx": 12, - "version": "5", - "when": 1772534950328, - "tag": "0012_lethal_lilith", - "breakpoints": true - }, - { - "idx": 13, - "version": "5", - "when": 1772573402457, - "tag": "0013_faithful_marvex", - "breakpoints": true - }, - { - "idx": 14, - "version": "5", - "when": 1772576220593, - "tag": "0014_modern_triton", - "breakpoints": true - }, - { - "idx": 15, - "version": "5", - "when": 1772582468257, - "tag": "0015_closed_tigra", - "breakpoints": true - }, - { - "idx": 16, - "version": "5", - "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 - }, - { - "idx": 21, - "version": "5", - "when": 1778249922872, - "tag": "0021_glorious_khan", - "breakpoints": true - }, - { - "idx": 22, - "version": "5", - "when": 1778252207674, - "tag": "0022_dazzling_namor", - "breakpoints": true - }, - { - "idx": 23, - "version": "5", - "when": 1778434170430, - "tag": "0023_misty_luckman", - "breakpoints": true - }, - { - "idx": 24, - "version": "5", - "when": 1778776694053, - "tag": "0024_many_speed", - "breakpoints": true - }, - { - "idx": 25, - "version": "5", - "when": 1778858762935, - "tag": "0025_demonic_mother_askani", - "breakpoints": true - }, - { - "idx": 26, - "version": "5", - "when": 1779283712737, - "tag": "0026_pretty_the_professor", - "breakpoints": true - }, - { - "idx": 27, - "version": "5", - "when": 1780932760351, - "tag": "0027_giant_shinko_yamashiro", - "breakpoints": true - }, - { - "idx": 28, - "version": "5", - "when": 1780937790068, - "tag": "0028_nebulous_madame_masque", - "breakpoints": true - }, - { - "idx": 29, - "version": "5", - "when": 1780937848351, - "tag": "0029_blushing_pretty_boy", - "breakpoints": true - }, - { - "idx": 30, - "version": "5", - "when": 1780940100362, - "tag": "0030_add_folder_settings", - "breakpoints": true - }, - { - "idx": 31, - "version": "5", - "when": 1781271269353, - "tag": "0031_add_auth_api_keys_user_created_index", - "breakpoints": true - }, - { - "idx": 32, - "version": "5", - "when": 1782850635482, - "tag": "0032_past_lester", - "breakpoints": true - }, - { - "idx": 33, - "version": "5", - "when": 1782853232527, - "tag": "0033_fluffy_gamora", - "breakpoints": true - }, - { - "idx": 34, - "version": "5", - "when": 1782854064505, - "tag": "0034_fluffy_falcon", - "breakpoints": true - }, - { - "idx": 35, - "version": "5", - "when": 1784393810560, - "tag": "0035_agent_api", - "breakpoints": true - }, - { - "idx": 36, - "version": "5", - "when": 1784402487715, - "tag": "0036_premium_master_chief", - "breakpoints": true - }, - { - "idx": 37, - "version": "5", - "when": 1785452585612, - "tag": "0037_integration_installations", - "breakpoints": true - }, - { - "idx": 38, - "version": "5", - "when": 1785892430217, - "tag": "0038_mean_ozymandias", - "breakpoints": true - }, - { - "idx": 39, - "version": "5", - "when": 1787096888361, - "tag": "0039_magical_vermin", - "breakpoints": true - }, - { - "idx": 40, - "version": "5", - "when": 1788366318415, - "tag": "0040_recording_jobs", - "breakpoints": true - }, - { - "idx": 41, - "version": "5", - "when": 1788384021541, - "tag": "0041_saml_sso", - "breakpoints": true - }, - { - "idx": 42, - "version": "5", - "when": 1788437051131, - "tag": "0042_perf_indexes", - "breakpoints": true - }, - { - "idx": 43, - "version": "5", - "when": 1788695529193, - "tag": "0043_flimsy_caretaker", - "breakpoints": true - }, - { - "idx": 44, - "version": "5", - "when": 1789135858521, - "tag": "0044_awesome_sugar_man", - "breakpoints": true - }, - { - "idx": 45, - "version": "5", - "when": 1789136779085, - "tag": "0045_steep_forge", - "breakpoints": true - }, - { - "idx": 46, - "version": "5", - "when": 1790071320615, - "tag": "0046_mcp_oauth", - "breakpoints": true - }, - { - "idx": 47, - "version": "5", - "when": 1790073320452, - "tag": "0047_mcp_client_lifecycle", - "breakpoints": true - }, - { - "idx": 48, - "version": "5", - "when": 1790073970527, - "tag": "0048_mcp_registration_quota", - "breakpoints": true - }, - { - "idx": 49, - "version": "5", - "when": 1790203121737, - "tag": "0049_jittery_professor_monster", - "breakpoints": true - } - ] -} + "version": "5", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1743020179593, + "tag": "0000_brown_sunfire", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1749268354138, + "tag": "0001_white_young_avengers", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1750935538683, + "tag": "0002_dusty_maginty", + "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1761710697286, + "tag": "0003_thin_gressill", + "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1761711378574, + "tag": "0004_video-org-id", + "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1761711605408, + "tag": "0005_video-org-id-required", + "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1762410865419, + "tag": "0006_hesitant_stone_men", + "breakpoints": true + }, + { + "idx": 7, + "version": "5", + "when": 1762428551824, + "tag": "0007_public_toxin", + "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1762428905323, + "tag": "0008_fat_ender_wiggin", + "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1768139432782, + "tag": "0009_easy_ulik", + "breakpoints": true + }, + { + "idx": 10, + "version": "5", + "when": 1738505600000, + "tag": "0010_video_uploads_bigint", + "breakpoints": true + }, + { + "idx": 11, + "version": "5", + "when": 1771325011010, + "tag": "0011_public_inhumans", + "breakpoints": true + }, + { + "idx": 12, + "version": "5", + "when": 1772534950328, + "tag": "0012_lethal_lilith", + "breakpoints": true + }, + { + "idx": 13, + "version": "5", + "when": 1772573402457, + "tag": "0013_faithful_marvex", + "breakpoints": true + }, + { + "idx": 14, + "version": "5", + "when": 1772576220593, + "tag": "0014_modern_triton", + "breakpoints": true + }, + { + "idx": 15, + "version": "5", + "when": 1772582468257, + "tag": "0015_closed_tigra", + "breakpoints": true + }, + { + "idx": 16, + "version": "5", + "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 + }, + { + "idx": 21, + "version": "5", + "when": 1778249922872, + "tag": "0021_glorious_khan", + "breakpoints": true + }, + { + "idx": 22, + "version": "5", + "when": 1778252207674, + "tag": "0022_dazzling_namor", + "breakpoints": true + }, + { + "idx": 23, + "version": "5", + "when": 1778434170430, + "tag": "0023_misty_luckman", + "breakpoints": true + }, + { + "idx": 24, + "version": "5", + "when": 1778776694053, + "tag": "0024_many_speed", + "breakpoints": true + }, + { + "idx": 25, + "version": "5", + "when": 1778858762935, + "tag": "0025_demonic_mother_askani", + "breakpoints": true + }, + { + "idx": 26, + "version": "5", + "when": 1779283712737, + "tag": "0026_pretty_the_professor", + "breakpoints": true + }, + { + "idx": 27, + "version": "5", + "when": 1780932760351, + "tag": "0027_giant_shinko_yamashiro", + "breakpoints": true + }, + { + "idx": 28, + "version": "5", + "when": 1780937790068, + "tag": "0028_nebulous_madame_masque", + "breakpoints": true + }, + { + "idx": 29, + "version": "5", + "when": 1780937848351, + "tag": "0029_blushing_pretty_boy", + "breakpoints": true + }, + { + "idx": 30, + "version": "5", + "when": 1780940100362, + "tag": "0030_add_folder_settings", + "breakpoints": true + }, + { + "idx": 31, + "version": "5", + "when": 1781271269353, + "tag": "0031_add_auth_api_keys_user_created_index", + "breakpoints": true + }, + { + "idx": 32, + "version": "5", + "when": 1782850635482, + "tag": "0032_past_lester", + "breakpoints": true + }, + { + "idx": 33, + "version": "5", + "when": 1782853232527, + "tag": "0033_fluffy_gamora", + "breakpoints": true + }, + { + "idx": 34, + "version": "5", + "when": 1782854064505, + "tag": "0034_fluffy_falcon", + "breakpoints": true + }, + { + "idx": 35, + "version": "5", + "when": 1784393810560, + "tag": "0035_agent_api", + "breakpoints": true + }, + { + "idx": 36, + "version": "5", + "when": 1784402487715, + "tag": "0036_premium_master_chief", + "breakpoints": true + }, + { + "idx": 37, + "version": "5", + "when": 1785452585612, + "tag": "0037_integration_installations", + "breakpoints": true + }, + { + "idx": 38, + "version": "5", + "when": 1785892430217, + "tag": "0038_mean_ozymandias", + "breakpoints": true + }, + { + "idx": 39, + "version": "5", + "when": 1787096888361, + "tag": "0039_magical_vermin", + "breakpoints": true + }, + { + "idx": 40, + "version": "5", + "when": 1788366318415, + "tag": "0040_recording_jobs", + "breakpoints": true + }, + { + "idx": 41, + "version": "5", + "when": 1788384021541, + "tag": "0041_saml_sso", + "breakpoints": true + }, + { + "idx": 42, + "version": "5", + "when": 1788437051131, + "tag": "0042_perf_indexes", + "breakpoints": true + }, + { + "idx": 43, + "version": "5", + "when": 1788695529193, + "tag": "0043_flimsy_caretaker", + "breakpoints": true + }, + { + "idx": 44, + "version": "5", + "when": 1789135858521, + "tag": "0044_awesome_sugar_man", + "breakpoints": true + }, + { + "idx": 45, + "version": "5", + "when": 1789136779085, + "tag": "0045_steep_forge", + "breakpoints": true + }, + { + "idx": 46, + "version": "5", + "when": 1790071320615, + "tag": "0046_mcp_oauth", + "breakpoints": true + }, + { + "idx": 47, + "version": "5", + "when": 1790073320452, + "tag": "0047_mcp_client_lifecycle", + "breakpoints": true + }, + { + "idx": 48, + "version": "5", + "when": 1790073970527, + "tag": "0048_mcp_registration_quota", + "breakpoints": true + }, + { + "idx": 49, + "version": "5", + "when": 1790203121737, + "tag": "0049_jittery_professor_monster", + "breakpoints": true + }, + { + "idx": 50, + "version": "5", + "when": 1791345311370, + "tag": "0050_productive_christian_walker", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 8c19b1fd20f..736e66c6518 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -1652,6 +1652,88 @@ export const importedVideos = mysqlTable( ], ); +export type LoomImportJobStatus = + | "checking" + | "awaiting_upgrade" + | "importing" + | "completed" + | "cancelled"; + +export type LoomImportItemStatus = + | "pending" + | "ready" + | "importing" + | "complete" + | "failed" + | "skipped" + | "cancelled"; + +export const loomImportJobs = mysqlTable( + "loom_import_jobs", + { + id: nanoId("id").notNull().primaryKey(), + orgId: nanoIdRequired("org_id").$type(), + createdById: nanoIdRequired("created_by_id").$type(), + fileName: varchar("file_name", { length: 255 }).notNull(), + status: varchar("status", { length: 32 }) + .$type() + .notNull() + .default("checking"), + totalCount: int("total_count").notNull(), + createdAt: datetime("created_at", { fsp: 3 }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: datetime("updated_at", { fsp: 3 }) + .notNull() + .$defaultFn(() => new Date()), + startedAt: datetime("started_at", { fsp: 3 }), + completedAt: datetime("completed_at", { fsp: 3 }), + }, + (table) => [ + index("org_creator_created_idx").on( + table.orgId, + table.createdById, + table.createdAt, + ), + index("status_updated_idx").on(table.status, table.updatedAt), + ], +); + +export const loomImportJobItems = mysqlTable( + "loom_import_job_items", + { + id: nanoId("id").notNull().primaryKey(), + jobId: nanoIdRequired("job_id"), + rowNumber: int("csv_row").notNull(), + loomUrl: varchar("loom_url", { length: 1024 }).notNull(), + loomVideoId: varchar("loom_video_id", { length: 64 }), + ownerEmail: varchar("owner_email", { length: 255 }), + spaceName: varchar("space_name", { length: 255 }), + status: varchar("status", { length: 16 }) + .$type() + .notNull() + .default("pending"), + ownerId: nanoIdNullable("owner_id").$type(), + spaceId: nanoIdNullable("space_id").$type(), + videoId: nanoIdNullable("video_id").$type(), + title: varchar("title", { length: 255 }), + loomCreatedAt: datetime("loom_created_at", { fsp: 3 }), + durationSeconds: float("duration_seconds"), + width: int("width"), + height: int("height"), + thumbnailUrl: varchar("thumbnail_url", { length: 1024 }), + error: varchar("error", { length: 512 }), + updatedAt: datetime("updated_at", { fsp: 3 }) + .notNull() + .$defaultFn(() => new Date()), + }, + (table) => [ + uniqueIndex("job_row_idx").on(table.jobId, table.rowNumber), + index("job_status_row_idx").on(table.jobId, table.status, table.rowNumber), + index("video_id_idx").on(table.videoId), + ], +); + export const developerApps = mysqlTable( "developer_apps", { From 591137b8289b7bd5c16083ccbc46a285afeb6d22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 04:49:07 +0000 Subject: [PATCH 02/39] feat: run Loom CSV imports as durable server-side jobs Imports are created in one request (up to 2,000 rows), checked against Loom with aliased GraphQL batches, then dispatched a few videos at a time from a durable workflow. Each finished video refills the window, so the browser no longer drives batches and the tab can be closed. Imported Caps keep their Loom title, length, size and original recording date, and the upload row now records the raw file key so retries and stalled-pipeline recovery work for Loom imports. Co-authored-by: Richie McIlroy --- .../integration/loom-import-jobs.test.ts | 589 +++++++++++ .../__tests__/unit/loom-import-csv.test.ts | 228 +++++ apps/web/__tests__/unit/loom-import.test.ts | 450 --------- .../unit/media-processing-workflows.test.ts | 20 + apps/web/actions/loom-import.ts | 101 ++ apps/web/actions/loom.ts | 457 +-------- .../recover-failed-video-processing/route.ts | 23 +- apps/web/app/api/import/loom/jobs/route.ts | 133 +++ apps/web/lib/loom-import/csv.ts | 462 +++++++++ apps/web/lib/loom-import/dispatch.ts | 496 +++++++++ apps/web/lib/loom-import/jobs.ts | 954 ++++++++++++++++++ apps/web/lib/loom-import/loom-api.ts | 189 ++++ apps/web/lib/loom-import/recovery.ts | 110 ++ apps/web/lib/loom-import/report.ts | 47 + apps/web/lib/loom-import/snapshot.ts | 161 +++ apps/web/lib/loom-import/status.ts | 219 ++++ apps/web/workflows/import-loom-video.ts | 16 + apps/web/workflows/loom-import-job.ts | 41 + packages/env/server.ts | 6 + 19 files changed, 3815 insertions(+), 887 deletions(-) create mode 100644 apps/web/__tests__/integration/loom-import-jobs.test.ts create mode 100644 apps/web/__tests__/unit/loom-import-csv.test.ts create mode 100644 apps/web/actions/loom-import.ts create mode 100644 apps/web/app/api/import/loom/jobs/route.ts create mode 100644 apps/web/lib/loom-import/csv.ts create mode 100644 apps/web/lib/loom-import/dispatch.ts create mode 100644 apps/web/lib/loom-import/jobs.ts create mode 100644 apps/web/lib/loom-import/loom-api.ts create mode 100644 apps/web/lib/loom-import/recovery.ts create mode 100644 apps/web/lib/loom-import/report.ts create mode 100644 apps/web/lib/loom-import/snapshot.ts create mode 100644 apps/web/lib/loom-import/status.ts create mode 100644 apps/web/workflows/loom-import-job.ts diff --git a/apps/web/__tests__/integration/loom-import-jobs.test.ts b/apps/web/__tests__/integration/loom-import-jobs.test.ts new file mode 100644 index 00000000000..f950919c4d0 --- /dev/null +++ b/apps/web/__tests__/integration/loom-import-jobs.test.ts @@ -0,0 +1,589 @@ +import { randomUUID } from "node:crypto"; +import { + importedVideos, + loomImportJobItems, + loomImportJobs, + organizationMembers, + organizations, + spaceMembers, + spaces, + spaceVideos, + users, + videos, + videoUploads, +} from "@cap/database/schema"; +import { Organisation, User, type Video } from "@cap/web-domain"; +import { and, asc, eq } from "drizzle-orm"; +import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; +import { Effect, Option } from "effect"; +import { createPool, type Pool } from "mysql2/promise"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const fixture = vi.hoisted(() => ({ + database: undefined as MySql2Database | undefined, + start: vi.fn(), + concurrency: "2", +})); + +vi.mock("server-only", () => ({})); +vi.mock("@cap/database", () => ({ + db: () => { + if (!fixture.database) throw new Error("Test database is not connected."); + return fixture.database; + }, +})); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + NODE_ENV: "test", + serverEnv: () => ({ + CAP_VIDEOS_DEFAULT_PUBLIC: true, + LOOM_IMPORT_CONCURRENCY: fixture.concurrency, + WEB_URL: "https://cap.test", + }), +})); +vi.mock("@cap/database/loops/queue", () => ({ + enqueueLoopsSync: vi.fn(async () => undefined), +})); +vi.mock("@cap/web-backend/src/Storage/index", () => ({ + Storage: { + getWritableAccessForUser: () => + Effect.succeed({ + bucketId: Option.none(), + storageIntegrationId: Option.none(), + }), + }, +})); +vi.mock("@/lib/workflow-runtime", () => ({ + runWorkflowPromise: (effect: Effect.Effect) => + Effect.runPromise(effect), +})); +vi.mock("workflow/api", () => ({ start: fixture.start })); +vi.mock("@/workflows/import-loom-video", () => ({ + importLoomVideoWorkflow: "importLoomVideoWorkflow", +})); +vi.mock("@/workflows/loom-import-job", () => ({ + loomImportJobWorkflow: "loomImportJobWorkflow", +})); + +import { + dispatchLoomImportForVideo, + dispatchLoomImportJob, +} from "@/lib/loom-import/dispatch"; +import { + cancelLoomImportJob, + createLoomImportJob, + listLoomImportJobs, + markLoomImportJobStarting, + prepareLoomImportJob, + resetFailedLoomImportItems, + resolveLoomImportJob, +} from "@/lib/loom-import/jobs"; +import { getLoomImportSnapshot } from "@/lib/loom-import/snapshot"; + +const databaseUrl = process.env.CAP_LOOM_IMPORT_TEST_DATABASE_URL; + +const LOOM = { + ok1: "0dd0a01e10c742b28dbea75082c08635", + ok2: "31f430c1a1e744b8a7b6c18a26982c71", + ok3: "2bff5385f32643ea9d065227ebfadd0d", + ok4: "41f6aad19fb74048a8e9dbf3105b5a6a", + private: "43e16ce2319c4171ba9d09f7999eca16", + missing: "ffffffffffffffffffffffffffffffff", +}; + +const RECORDED = { + [LOOM.ok1]: "2022-02-15T20:02:03.132Z", + [LOOM.ok2]: "2022-06-22T19:17:16.020Z", + [LOOM.ok3]: "2022-06-22T19:17:34.669Z", + [LOOM.ok4]: "2023-08-18T20:00:46.121Z", +}; + +function database() { + if (!fixture.database) throw new Error("Test database is not connected."); + return fixture.database; +} + +function id() { + return randomUUID().replaceAll("-", "").slice(0, 15); +} + +function share(loomId: string) { + return `https://www.loom.com/share/${loomId}`; +} + +const loomFetch = vi.fn( + async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { + variables: Record; + }; + const data: Record = {}; + for (const [key, loomId] of Object.entries(body.variables)) { + const alias = key.replace("id", "v"); + if (loomId === LOOM.private) { + data[alias] = { __typename: "PrivateVideo", id: loomId }; + } else if (RECORDED[loomId]) { + data[alias] = { + __typename: "RegularUserVideo", + name: `Video ${loomId.slice(0, 4)}`, + createdAt: RECORDED[loomId], + thumbnails: { + default: `https://cdn.loom.com/sessions/thumbnails/${loomId}-00001.jpg`, + }, + video_properties: { duration: 120.5, width: 1920, height: 1080 }, + }; + } else { + data[alias] = null; + } + } + return Response.json({ data }); + }, +) as unknown as typeof fetch; + +async function makeOrganization({ pro }: { pro: boolean }) { + const ownerId = User.UserId.make(id()); + const orgId = Organisation.OrganisationId.make(id()); + await database() + .insert(users) + .values({ + id: ownerId, + email: `${ownerId}@example.com`, + name: "Owner", + activeOrganizationId: orgId, + defaultOrgId: orgId, + stripeSubscriptionStatus: pro ? "active" : null, + }); + await database().insert(organizations).values({ + id: orgId, + name: "Acme", + ownerId, + }); + await database().insert(organizationMembers).values({ + id: id(), + organizationId: orgId, + userId: ownerId, + role: "owner", + }); + return { ownerId, orgId }; +} + +async function items(jobId: string) { + return database() + .select() + .from(loomImportJobItems) + .where(eq(loomImportJobItems.jobId, jobId)) + .orderBy(asc(loomImportJobItems.rowNumber)); +} + +async function jobStatus(jobId: string) { + const [job] = await database() + .select({ status: loomImportJobs.status }) + .from(loomImportJobs) + .where(eq(loomImportJobs.id, jobId)); + return job?.status; +} + +async function finishVideo(videoId: Video.VideoId) { + await database() + .delete(videoUploads) + .where(eq(videoUploads.videoId, videoId)); + return dispatchLoomImportForVideo(videoId); +} + +async function failVideo(videoId: Video.VideoId, error: string) { + await database() + .update(videoUploads) + .set({ phase: "error", processingError: error }) + .where(eq(videoUploads.videoId, videoId)); + return dispatchLoomImportForVideo(videoId); +} + +describe.runIf(Boolean(databaseUrl))( + "Loom CSV imports with an isolated MySQL database", + () => { + let pool: Pool | undefined; + + beforeAll(async () => { + if (!databaseUrl) throw new Error("Missing isolated test database URL."); + const url = new URL(databaseUrl); + if ( + url.protocol !== "mysql:" || + !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) || + !/^\/cap_loom_import_[a-z0-9_]+$/.test(url.pathname) + ) { + throw new Error("Loom import tests require a local test database."); + } + pool = createPool(databaseUrl); + fixture.database = drizzle(pool); + await database().select().from(loomImportJobs).limit(1); + }); + + afterAll(async () => { + await pool?.end(); + fixture.database = undefined; + }); + + beforeEach(() => { + fixture.start.mockReset().mockResolvedValue({ runId: "run" }); + fixture.concurrency = "2"; + loomFetch.mockClear(); + }); + + it("checks every link, waits for Pro on the free plan, then imports with original dates in a bounded window", async () => { + const { ownerId, orgId } = await makeOrganization({ pro: false }); + const teammate = `teammate-${id()}@example.com`; + const { jobId, totalCount } = await createLoomImportJob({ + userId: ownerId, + orgId, + fileName: "loom-export.csv", + rows: [ + { + rowNumber: 2, + loomUrl: share(LOOM.ok1), + ownerEmail: teammate, + spaceName: "Sales", + }, + { + rowNumber: 3, + loomUrl: `https://www.loom.com/share/My-Demo-${LOOM.ok2}?sid=1`, + }, + { rowNumber: 4, loomUrl: share(LOOM.ok3), spaceName: "sales" }, + { rowNumber: 5, loomUrl: share(LOOM.private) }, + { rowNumber: 6, loomUrl: share(LOOM.missing) }, + { rowNumber: 7, loomUrl: "https://example.com/not-loom" }, + { rowNumber: 8, loomUrl: share(LOOM.ok1) }, + { rowNumber: 9, loomUrl: share(LOOM.ok4) }, + ], + }); + expect(totalCount).toBe(8); + + await resolveLoomImportJob(jobId, { fetchImpl: loomFetch }); + expect(loomFetch).toHaveBeenCalledTimes(1); + const resolved = await items(jobId); + expect(resolved.map((item) => [item.rowNumber, item.status])).toEqual([ + [2, "ready"], + [3, "ready"], + [4, "ready"], + [5, "failed"], + [6, "failed"], + [7, "failed"], + [8, "skipped"], + [9, "ready"], + ]); + expect(resolved[0]).toMatchObject({ + title: `Video ${LOOM.ok1.slice(0, 4)}`, + durationSeconds: 120.5, + width: 1920, + thumbnailUrl: `https://cdn.loom.com/sessions/thumbnails/${LOOM.ok1}-00001.jpg`, + }); + expect(resolved[0]?.loomCreatedAt?.toISOString()).toBe( + RECORDED[LOOM.ok1], + ); + expect(resolved[1]?.loomVideoId).toBe(LOOM.ok2); + expect(resolved[3]?.error).toContain("private"); + expect(resolved[6]?.error).toBe("Same video as row 2."); + + expect(await prepareLoomImportJob(jobId)).toBe("awaiting_upgrade"); + const waiting = await getLoomImportSnapshot({ jobId, userId: ownerId }); + expect(waiting?.job).toMatchObject({ + status: "awaiting_upgrade", + canStart: false, + isPro: false, + }); + expect(waiting?.counts).toMatchObject({ + ready: 4, + failed: 3, + skipped: 1, + }); + expect(waiting?.totalDuration).toBeCloseTo(482); + expect(fixture.start).not.toHaveBeenCalled(); + expect( + await database().select().from(videos).where(eq(videos.orgId, orgId)), + ).toEqual([]); + + await database() + .update(users) + .set({ stripeSubscriptionStatus: "active" }) + .where(eq(users.id, ownerId)); + expect( + (await getLoomImportSnapshot({ jobId, userId: ownerId }))?.job.canStart, + ).toBe(true); + expect(await markLoomImportJobStarting(jobId)).toBe(true); + expect(await markLoomImportJobStarting(jobId)).toBe(false); + await resolveLoomImportJob(jobId, { fetchImpl: loomFetch }); + expect(await prepareLoomImportJob(jobId)).toBe("importing"); + + const [teammateUser] = await database() + .select({ id: users.id }) + .from(users) + .where(eq(users.email, teammate)); + expect(teammateUser).toBeDefined(); + const [salesSpace, ...otherSpaces] = await database() + .select({ id: spaces.id, name: spaces.name }) + .from(spaces) + .where(eq(spaces.organizationId, orgId)); + expect(otherSpaces).toEqual([]); + expect(salesSpace?.name).toBe("Sales"); + const members = await database() + .select({ userId: spaceMembers.userId, role: spaceMembers.role }) + .from(spaceMembers) + .where(eq(spaceMembers.spaceId, salesSpace?.id ?? "")); + expect(members).toEqual( + expect.arrayContaining([ + { userId: ownerId, role: "admin" }, + { userId: teammateUser?.id, role: "member" }, + ]), + ); + + expect(await dispatchLoomImportJob(jobId)).toEqual({ + started: 2, + completed: false, + }); + let state = await items(jobId); + expect( + state + .filter((item) => item.status === "importing") + .map((item) => item.rowNumber), + ).toEqual([2, 3]); + expect(fixture.start).toHaveBeenCalledTimes(2); + const first = state[0]; + const firstVideoId = first?.videoId as Video.VideoId; + const [firstVideo] = await database() + .select() + .from(videos) + .where(eq(videos.id, firstVideoId)); + expect(firstVideo).toMatchObject({ + ownerId: teammateUser?.id, + orgId, + name: `Video ${LOOM.ok1.slice(0, 4)}`, + duration: 120.5, + transcriptionStatus: null, + }); + expect(firstVideo?.metadata).toEqual({ + customCreatedAt: RECORDED[LOOM.ok1], + }); + expect(firstVideo?.effectiveCreatedAt?.toISOString().slice(0, 19)).toBe( + RECORDED[LOOM.ok1]?.slice(0, 19), + ); + const [upload] = await database() + .select() + .from(videoUploads) + .where(eq(videoUploads.videoId, firstVideoId)); + expect(upload?.rawFileKey).toBe( + `${teammateUser?.id}/${firstVideoId}/raw-upload.mp4`, + ); + expect( + await database() + .select() + .from(spaceVideos) + .where(eq(spaceVideos.videoId, firstVideoId)), + ).toHaveLength(1); + expect(fixture.start.mock.calls[0]?.[1]?.[0]).toMatchObject({ + videoId: firstVideoId, + userId: teammateUser?.id, + loomVideoId: LOOM.ok1, + }); + + expect(await dispatchLoomImportJob(jobId)).toEqual({ + started: 0, + completed: false, + }); + + await finishVideo(firstVideoId); + state = await items(jobId); + expect(state[0]?.status).toBe("complete"); + expect( + state + .filter((item) => item.status === "importing") + .map((item) => item.rowNumber), + ).toEqual([3, 4]); + + await failVideo( + state[1]?.videoId as Video.VideoId, + "Media server unavailable", + ); + state = await items(jobId); + expect(state[1]).toMatchObject({ + status: "failed", + error: "Media server unavailable", + }); + expect(state[7]?.status).toBe("importing"); + + const live = await getLoomImportSnapshot({ jobId, userId: ownerId }); + expect(live?.counts).toMatchObject({ + imported: 1, + importing: 2, + failed: 4, + skipped: 1, + }); + const earlier = new Date(Date.now() - 60_000); + await database() + .update(loomImportJobItems) + .set({ updatedAt: earlier }) + .where(eq(loomImportJobItems.jobId, jobId)); + for (const item of state) { + if (!item.videoId) continue; + await database() + .update(videos) + .set({ updatedAt: earlier }) + .where(eq(videos.id, item.videoId)); + await database() + .update(videoUploads) + .set({ updatedAt: earlier }) + .where(eq(videoUploads.videoId, item.videoId)); + } + const settledView = await getLoomImportSnapshot({ + jobId, + userId: ownerId, + }); + expect(settledView?.cursor).toBeLessThan(Date.now() - 50_000); + const lastPoll = Date.now() - 1_000; + await database() + .update(videoUploads) + .set({ processingProgress: 40, updatedAt: new Date() }) + .where(eq(videoUploads.videoId, state[7]?.videoId as Video.VideoId)); + const delta = await getLoomImportSnapshot({ + jobId, + userId: ownerId, + since: lastPoll + 5_000, + }); + expect(delta?.full).toBe(false); + expect( + delta?.items.map((item) => [item.row, item.status, item.progress]), + ).toEqual([[9, "importing", 40]]); + expect(delta?.counts.total).toBe(8); + + await finishVideo(state[2]?.videoId as Video.VideoId); + await finishVideo(state[7]?.videoId as Video.VideoId); + expect(await jobStatus(jobId)).toBe("completed"); + + const retried = await resetFailedLoomImportItems(jobId); + expect(retried).toBe(3); + expect(await jobStatus(jobId)).toBe("checking"); + state = await items(jobId); + expect(state[1]).toMatchObject({ status: "ready", error: null }); + expect(state[4]?.status).toBe("pending"); + await resolveLoomImportJob(jobId, { fetchImpl: loomFetch }); + expect(await prepareLoomImportJob(jobId)).toBe("importing"); + fixture.start.mockClear(); + expect((await dispatchLoomImportJob(jobId)).started).toBe(1); + expect(fixture.start.mock.calls[0]?.[1]?.[0]).toMatchObject({ + videoId: state[1]?.videoId, + reuseExistingRawUpload: true, + }); + + const [summary] = await listLoomImportJobs({ userId: ownerId, orgId }); + expect(summary).toMatchObject({ id: jobId, imported: 3, totalCount: 8 }); + }); + + it("skips videos already in Cap and stops queued work when cancelled", async () => { + const { ownerId, orgId } = await makeOrganization({ pro: true }); + fixture.concurrency = "1"; + const first = await createLoomImportJob({ + userId: ownerId, + orgId, + fileName: "first.csv", + rows: [{ rowNumber: 2, loomUrl: share(LOOM.ok1) }], + }); + await resolveLoomImportJob(first.jobId, { fetchImpl: loomFetch }); + await prepareLoomImportJob(first.jobId); + await dispatchLoomImportJob(first.jobId); + + const second = await createLoomImportJob({ + userId: ownerId, + orgId, + fileName: "second.csv", + rows: [ + { rowNumber: 2, loomUrl: share(LOOM.ok1) }, + { rowNumber: 3, loomUrl: share(LOOM.ok2) }, + { rowNumber: 4, loomUrl: share(LOOM.ok3) }, + ], + }); + await resolveLoomImportJob(second.jobId, { fetchImpl: loomFetch }); + const [duplicate] = await items(second.jobId); + const [existing] = await database() + .select({ id: importedVideos.id }) + .from(importedVideos) + .where( + and( + eq(importedVideos.orgId, orgId), + eq(importedVideos.sourceId, LOOM.ok1), + ), + ); + expect(duplicate).toMatchObject({ + status: "skipped", + error: "Already in Cap.", + videoId: existing?.id, + }); + + await prepareLoomImportJob(second.jobId); + await dispatchLoomImportJob(second.jobId); + await cancelLoomImportJob(second.jobId); + const state = await items(second.jobId); + expect(state.map((item) => item.status)).toEqual([ + "skipped", + "importing", + "cancelled", + ]); + expect(await jobStatus(second.jobId)).toBe("cancelled"); + expect(await dispatchLoomImportJob(second.jobId)).toEqual({ + started: 0, + completed: false, + }); + }); + + it("rejects files over the 2,000 video limit without creating anything", async () => { + const { ownerId, orgId } = await makeOrganization({ pro: true }); + const rows = Array.from({ length: 2001 }, (_, index) => ({ + rowNumber: index + 2, + loomUrl: share(`a${index.toString(16).padStart(31, "0")}`), + })); + await expect( + createLoomImportJob({ + userId: ownerId, + orgId, + fileName: "big.csv", + rows, + }), + ).rejects.toThrow("Split your list"); + expect( + await database() + .select() + .from(loomImportJobs) + .where(eq(loomImportJobs.orgId, orgId)), + ).toEqual([]); + }); + + it("creates a 2,000 video import in one request", async () => { + const { ownerId, orgId } = await makeOrganization({ pro: true }); + const rows = Array.from({ length: 2000 }, (_, index) => ({ + rowNumber: index + 2, + loomUrl: share(`b${index.toString(16).padStart(31, "0")}`), + })); + const startedAt = performance.now(); + const { jobId, totalCount } = await createLoomImportJob({ + userId: ownerId, + orgId, + fileName: "big.csv", + rows, + }); + const elapsed = performance.now() - startedAt; + expect(totalCount).toBe(2000); + expect(await items(jobId)).toHaveLength(2000); + const snapshotStartedAt = performance.now(); + const snapshot = await getLoomImportSnapshot({ jobId, userId: ownerId }); + const snapshotElapsed = performance.now() - snapshotStartedAt; + expect(snapshot?.counts.checking).toBe(2000); + console.info( + `[loom-import benchmark] create 2000 rows: ${elapsed.toFixed(0)}ms, full snapshot: ${snapshotElapsed.toFixed(0)}ms`, + ); + }); + }, +); diff --git a/apps/web/__tests__/unit/loom-import-csv.test.ts b/apps/web/__tests__/unit/loom-import-csv.test.ts new file mode 100644 index 00000000000..5d416237ad0 --- /dev/null +++ b/apps/web/__tests__/unit/loom-import-csv.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { + buildLoomImportPlan, + detectLoomImportMapping, + extractLoomVideoId, + LOOM_IMPORT_MAX_ROWS, + parseCsv, + parseCsvRecords, + tableFromPastedLinks, +} from "@/lib/loom-import/csv"; + +const ID_A = "0dd0a01e10c742b28dbea75082c08635"; +const ID_B = "31f430c1a1e744b8a7b6c18a26982c71"; + +describe("parseCsvRecords", () => { + it("handles quotes, escaped quotes, embedded newlines and CRLF", () => { + const text = + '\uFEFFtitle,link\r\n"Quarterly ""all hands""",https://www.loom.com/share/a\r\n"Line one\nline two",https://www.loom.com/share/b\r\n'; + expect(parseCsvRecords(text)).toEqual([ + ["title", "link"], + ['Quarterly "all hands"', "https://www.loom.com/share/a"], + ["Line one\nline two", "https://www.loom.com/share/b"], + ]); + }); + + it("keeps blank lines so row numbers match the spreadsheet, and rejects unclosed quotes", () => { + expect(parseCsvRecords("a,,c\n\n,,\nd,e,\n\n,,\n")).toEqual([ + ["a", "", "c"], + [""], + ["", "", ""], + ["d", "e", ""], + ]); + expect(() => parseCsvRecords('a,"unfinished\n')).toThrow("unclosed"); + }); +}); + +describe("parseCsv", () => { + it("detects semicolon and tab separated exports", () => { + expect( + parseCsv("Video URL;Owner\nhttps://loom.com/share/x;a@b.co").rows, + ).toEqual([["https://loom.com/share/x", "a@b.co"]]); + expect( + parseCsv("Video URL\tOwner\nhttps://loom.com/share/x\ta@b.co").delimiter, + ).toBe("\t"); + }); + + it("treats a plain list of Loom links as rows instead of a header", () => { + const table = parseCsv( + `https://www.loom.com/share/${ID_A}\nhttps://www.loom.com/share/${ID_B}\n`, + ); + expect(table.headerless).toBe(true); + expect(table.rows).toHaveLength(2); + }); +}); + +describe("extractLoomVideoId", () => { + it.each([ + [`https://www.loom.com/share/${ID_A}`, ID_A], + [`https://www.loom.com/share/${ID_A}?sid=123#t=10`, ID_A], + [`https://www.loom.com/embed/${ID_A}`, ID_A], + [`https://loom.com/share/Weekly-update-for-the-team-${ID_A}`, ID_A], + [`www.loom.com/share/${ID_A.toUpperCase()}`, ID_A], + [ID_B, ID_B], + ])("reads %s", (value, expected) => { + expect(extractLoomVideoId(value)).toBe(expected); + }); + + it.each([ + "", + "https://example.com/share/0dd0a01e10c742b28dbea75082c08635", + "https://www.loom.com/", + "https://notloom.com/share/0dd0a01e10c742b28dbea75082c08635", + "just some text", + ])("ignores %s", (value) => { + expect(extractLoomVideoId(value)).toBeNull(); + }); +}); + +describe("detectLoomImportMapping", () => { + it("finds columns by their values when headers are unfamiliar", () => { + const table = parseCsv( + [ + "Name,Recorder,Where,Recording", + `Kickoff,ana@acme.com,Sales,https://www.loom.com/share/${ID_A}`, + `Demo,sam@acme.com,Sales,https://www.loom.com/share/${ID_B}`, + ].join("\n"), + ); + expect(detectLoomImportMapping(table)).toEqual({ + loomUrl: 3, + ownerEmail: 1, + spaceName: undefined, + }); + }); + + it("maps the Cap template and Loom style exports", () => { + const template = parseCsv( + `loom_video_url,user_email,space_name\nhttps://www.loom.com/share/${ID_A},a@b.co,Sales`, + ); + expect(detectLoomImportMapping(template)).toEqual({ + loomUrl: 0, + ownerEmail: 1, + spaceName: 2, + }); + const loomExport = parseCsv( + `Video ID,Video Title,Creator Email,Workspace\n${ID_A},Intro,a@b.co,Acme`, + ); + expect(detectLoomImportMapping(loomExport)).toEqual({ + loomUrl: 0, + ownerEmail: 2, + spaceName: undefined, + }); + }); +}); + +describe("buildLoomImportPlan", () => { + const table = parseCsv( + [ + "loom_video_url,user_email,space_name", + `https://www.loom.com/share/${ID_A},Ana@Acme.com, Sales Team `, + "https://example.com/video,ana@acme.com,", + `https://www.loom.com/share/${ID_B},not-an-email,`, + `https://www.loom.com/share/${ID_A},sam@acme.com,`, + ",,", + `${ID_B},sam@acme.com,Design`, + ].join("\n"), + ); + + it("explains every row it leaves out and normalizes the rest", () => { + const plan = buildLoomImportPlan(table, detectLoomImportMapping(table), { + allowOwners: true, + }); + expect(plan.rows).toEqual([ + { + rowNumber: 2, + loomUrl: `https://www.loom.com/share/${ID_A}`, + ownerEmail: "ana@acme.com", + spaceName: "Sales Team", + }, + { + rowNumber: 7, + loomUrl: `https://www.loom.com/share/${ID_B}`, + ownerEmail: "sam@acme.com", + spaceName: "Design", + }, + ]); + expect(plan.issues.map((issue) => [issue.rowNumber, issue.reason])).toEqual( + [ + [3, "not_loom"], + [4, "bad_email"], + [5, "duplicate"], + ], + ); + expect(plan.issues[2]?.duplicateOf).toBe(2); + expect(plan).toMatchObject({ owners: 2, spaces: 2, overLimit: false }); + }); + + it("ignores owners and spaces for people who can only import for themselves", () => { + const plan = buildLoomImportPlan(table, detectLoomImportMapping(table), { + allowOwners: false, + }); + expect(plan.rows.map((row) => [row.rowNumber, row.ownerEmail])).toEqual([ + [2, undefined], + [4, undefined], + ]); + expect(plan.issues.map((issue) => [issue.rowNumber, issue.reason])).toEqual( + [ + [3, "not_loom"], + [5, "duplicate"], + [7, "duplicate"], + ], + ); + }); + + it("flags files over the per-import limit", () => { + const rows = Array.from( + { length: LOOM_IMPORT_MAX_ROWS + 1 }, + (_, index) => [ + `https://www.loom.com/share/${index.toString(16).padStart(32, "0")}`, + ], + ); + const plan = buildLoomImportPlan( + { headers: ["url"], rows, delimiter: ",", headerless: false }, + { loomUrl: 0 }, + { allowOwners: true }, + ); + expect(plan.overLimit).toBe(true); + }); + + it("parses and plans 2,000 rows quickly", () => { + const csv = [ + "Video Title,Video URL,Creator Email,Space", + ...Array.from( + { length: 2000 }, + (_, index) => + `"Weekly update, part ${index}",https://www.loom.com/share/${index.toString(16).padStart(32, "0")},person${index % 40}@acme.com,Team ${index % 7}`, + ), + ].join("\n"); + const startedAt = performance.now(); + const parsed = parseCsv(csv); + const plan = buildLoomImportPlan(parsed, detectLoomImportMapping(parsed), { + allowOwners: true, + }); + const elapsed = performance.now() - startedAt; + expect(plan.rows).toHaveLength(2000); + expect(plan.owners).toBe(40); + expect(plan.spaces).toBe(7); + expect(elapsed).toBeLessThan(250); + }); +}); + +describe("tableFromPastedLinks", () => { + it("pulls Loom links out of free text", () => { + const table = tableFromPastedLinks( + `Here you go: https://www.loom.com/share/${ID_A}. And (https://loom.com/share/${ID_B})\nthanks!`, + ); + expect(table.rows).toEqual([ + [`https://www.loom.com/share/${ID_A}`], + [`https://loom.com/share/${ID_B}`], + ]); + }); + + it("accepts bare Loom video ids", () => { + expect(tableFromPastedLinks(`${ID_A}\n${ID_B}`).rows).toEqual([ + [ID_A], + [ID_B], + ]); + }); +}); diff --git a/apps/web/__tests__/unit/loom-import.test.ts b/apps/web/__tests__/unit/loom-import.test.ts index a8068f6e3b3..703354e9802 100644 --- a/apps/web/__tests__/unit/loom-import.test.ts +++ b/apps/web/__tests__/unit/loom-import.test.ts @@ -706,454 +706,4 @@ describe("importFromLoom", () => { ]) expect(conditions).toContain(required); }); - - it("rejects a CSV import when the current user is not an organization admin or owner", async () => { - getOrganizationAccessMock.mockResolvedValueOnce({ - id: "org-1", - ownerId: "owner-456", - memberId: "member-row", - role: "member", - }); - - const fetchMock = vi.mocked(fetch); - const { importFromLoomCsv } = await import("@/actions/loom"); - - const result = await importFromLoomCsv({ - orgId: "org-1" as never, - rows: [ - { - rowNumber: 2, - loomUrl: "https://www.loom.com/share/loom-abc1234567", - userEmail: "member@example.com", - }, - ], - }); - - expect(result).toEqual({ - success: false, - importedCount: 0, - failedCount: 0, - results: [], - error: - "Only organization admins and owners can import Loom videos from a CSV.", - }); - expect(getOrganizationAccessMock).toHaveBeenCalledWith("user-123", "org-1"); - expect(fetchMock).not.toHaveBeenCalled(); - expect(valuesMock).not.toHaveBeenCalled(); - }); - - it("provisions missing CSV users and starts imports for them", async () => { - whereMock - .mockReturnValueOnce(withLimit([])) - .mockReturnValueOnce(withLimit([])) - .mockReturnValueOnce(withLimit([])) - .mockReturnValueOnce(withLimit([])) - .mockReturnValueOnce(withLimit([{ ownerId: "owner-456" }])) - .mockReturnValueOnce( - withLimit([{ inviteQuota: 1, stripeSubscriptionId: null }]), - ) - .mockResolvedValueOnce([]); - - const fetchMock = vi.mocked(fetch); - fetchMock.mockImplementation(async (input) => { - const url = typeof input === "string" ? input : input.toString(); - - if (url.includes("/transcoded-url")) { - return { - ok: true, - status: 200, - text: async () => - JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }), - } as Response; - } - - if (url === "https://www.loom.com/graphql") { - return { - ok: true, - json: async () => ({ - data: { getVideo: { name: "Imported video" } }, - }), - } as Response; - } - - if (url.includes("/v1/oembed")) { - return { - ok: true, - json: async () => ({ duration: 42, width: 1920, height: 1080 }), - } as Response; - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - const { importFromLoomCsv } = await import("@/actions/loom"); - - const result = await importFromLoomCsv({ - orgId: "org-1" as never, - rows: [ - { - rowNumber: 2, - loomUrl: "https://www.loom.com/share/loom-abc1234567", - userEmail: "not-member@example.com", - }, - ], - }); - - expect(result).toEqual({ - success: true, - importedCount: 1, - failedCount: 0, - results: [ - { - rowNumber: 2, - userEmail: "not-member@example.com", - spaceName: undefined, - success: true, - videoId: "video-123", - error: undefined, - }, - ], - error: undefined, - }); - expect(fetchMock).toHaveBeenCalled(); - expect(valuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - email: "not-member@example.com", - activeOrganizationId: "org-1", - defaultOrgId: "org-1", - }), - ); - expect(valuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId: "org-1", - invitedEmail: "not-member@example.com", - invitedByUserId: "user-123", - role: "member", - }), - ); - expect(valuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId: "org-1", - userId: "video-123", - role: "member", - }), - ); - expect(storageGetWritableAccessForUserMock).toHaveBeenCalledWith( - "video-123", - "org-1", - ); - }); - - it("imports a CSV row without calling the Vercel firewall", async () => { - whereMock.mockImplementation((conditions: unknown) => { - const serializedConditions = JSON.stringify(conditions); - - if (serializedConditions.includes("sourceId")) { - return Promise.resolve( - serializedConditions.includes("loom-existing123") - ? [{ videoId: "existing-video" }] - : [], - ); - } - - return withLimit([{ userId: "member-123", email: "member@example.com" }]); - }); - - const fetchMock = vi.mocked(fetch); - fetchMock.mockImplementation(async (input) => { - const url = typeof input === "string" ? input : input.toString(); - - if (url.includes("/transcoded-url")) { - return { - ok: true, - status: 200, - text: async () => - JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }), - } as Response; - } - - if (url === "https://www.loom.com/graphql") { - return { - ok: true, - json: async () => ({ - data: { getVideo: { name: "Imported video" } }, - }), - } as Response; - } - - if (url.includes("/v1/oembed")) { - return { - ok: true, - json: async () => ({ duration: 42, width: 1920, height: 1080 }), - } as Response; - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - const { importFromLoomCsv } = await import("@/actions/loom"); - - const result = await importFromLoomCsv({ - orgId: "org-1" as never, - rows: [ - { - rowNumber: 2, - loomUrl: "https://www.loom.com/share/loom-existing123", - userEmail: "member@example.com", - }, - { - rowNumber: 3, - loomUrl: "https://www.loom.com/share/loom-newvideo123", - userEmail: "member@example.com", - }, - ], - }); - - expect(result).toEqual({ - success: true, - importedCount: 1, - failedCount: 1, - results: [ - { - rowNumber: 2, - userEmail: "member@example.com", - spaceName: undefined, - success: false, - error: "This Loom video has already been imported.", - }, - { - rowNumber: 3, - userEmail: "member@example.com", - spaceName: undefined, - success: true, - videoId: "video-123", - error: undefined, - }, - ], - error: undefined, - }); - expect(checkRateLimitMock).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalled(); - expect(startMock).toHaveBeenCalledTimes(1); - }); - - it("limits CSV imports to 500 rows", async () => { - const fetchMock = vi.mocked(fetch); - const { importFromLoomCsv } = await import("@/actions/loom"); - - const result = await importFromLoomCsv({ - orgId: "org-1" as never, - rows: Array.from({ length: 501 }, (_, index) => ({ - rowNumber: index + 2, - loomUrl: `https://www.loom.com/share/loom-${String(index).padStart(10, "0")}`, - userEmail: "member@example.com", - })), - }); - - expect(result).toEqual({ - success: false, - importedCount: 0, - failedCount: 501, - results: [], - error: - "CSV imports are limited to 500 rows at a time. Contact support to raise this limit.", - }); - expect(checkRateLimitMock).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); - expect(valuesMock).not.toHaveBeenCalled(); - }); - - it("starts CSV Loom imports for matched organization members when the current user is an organization admin", async () => { - getOrganizationAccessMock.mockResolvedValueOnce({ - id: "org-1", - ownerId: "owner-456", - memberId: "member-row", - role: "admin", - }); - whereMock - .mockReturnValueOnce( - withLimit([{ userId: "member-123", email: "member@example.com" }]), - ) - .mockResolvedValueOnce([]); - - const fetchMock = vi.mocked(fetch); - fetchMock.mockImplementation(async (input) => { - const url = typeof input === "string" ? input : input.toString(); - - if (url.includes("/transcoded-url")) { - return { - ok: true, - status: 200, - text: async () => - JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }), - } as Response; - } - - if (url === "https://www.loom.com/graphql") { - return { - ok: true, - json: async () => ({ - data: { getVideo: { name: "Imported video" } }, - }), - } as Response; - } - - if (url.includes("/v1/oembed")) { - return { - ok: true, - json: async () => ({ duration: 42, width: 1920, height: 1080 }), - } as Response; - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - const { importFromLoomCsv } = await import("@/actions/loom"); - - const result = await importFromLoomCsv({ - orgId: "org-1" as never, - rows: [ - { - rowNumber: 2, - loomUrl: "https://www.loom.com/share/loom-abc1234567", - userEmail: "MEMBER@example.com", - }, - ], - }); - - expect(result).toEqual({ - success: true, - importedCount: 1, - failedCount: 0, - results: [ - { - rowNumber: 2, - userEmail: "member@example.com", - spaceName: undefined, - success: true, - videoId: "video-123", - error: undefined, - }, - ], - error: undefined, - }); - expect(storageGetWritableAccessForUserMock).toHaveBeenCalledWith( - "member-123", - "org-1", - ); - expect(valuesMock).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - ownerId: "member-123", - }), - ); - expect(startMock).toHaveBeenCalledWith( - expect.anything(), - expect.arrayContaining([ - expect.objectContaining({ - userId: "member-123", - rawFileKey: "member-123/video-123/raw-upload.mp4", - }), - ]), - ); - }); - - it("creates missing spaces and adds CSV Loom imports to them", async () => { - whereMock - .mockReturnValueOnce( - withLimit([{ userId: "member-123", email: "member@example.com" }]), - ) - .mockResolvedValueOnce([]) - .mockReturnValueOnce(withLimit([])) - .mockReturnValueOnce(withLimit([])) - .mockReturnValueOnce(withLimit([])); - - const fetchMock = vi.mocked(fetch); - fetchMock.mockImplementation(async (input) => { - const url = typeof input === "string" ? input : input.toString(); - - if (url.includes("/transcoded-url")) { - return { - ok: true, - status: 200, - text: async () => - JSON.stringify({ url: "https://cdn.loom.com/video.mp4" }), - } as Response; - } - - if (url === "https://www.loom.com/graphql") { - return { - ok: true, - json: async () => ({ - data: { getVideo: { name: "Imported video" } }, - }), - } as Response; - } - - if (url.includes("/v1/oembed")) { - return { - ok: true, - json: async () => ({ duration: 42, width: 1920, height: 1080 }), - } as Response; - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - const { importFromLoomCsv } = await import("@/actions/loom"); - - const result = await importFromLoomCsv({ - orgId: "org-1" as never, - rows: [ - { - rowNumber: 2, - loomUrl: "https://www.loom.com/share/loom-abc1234567", - userEmail: "member@example.com", - spaceName: " Sales Team ", - }, - ], - }); - - expect(result).toEqual({ - success: true, - importedCount: 1, - failedCount: 0, - results: [ - { - rowNumber: 2, - userEmail: "member@example.com", - spaceName: "Sales Team", - success: true, - videoId: "video-123", - error: undefined, - }, - ], - error: undefined, - }); - expect(mockDb.transaction).toHaveBeenCalledTimes(2); - expect(valuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: "Sales Team", - organizationId: "org-1", - createdById: "user-123", - }), - ); - expect(valuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - spaceId: "video-123", - userId: "user-123", - role: "admin", - }), - ); - expect(valuesMock).toHaveBeenCalledWith( - expect.objectContaining({ - spaceId: "video-123", - videoId: "video-123", - addedById: "user-123", - }), - ); - expect(revalidatePathMock).toHaveBeenCalledWith( - "/dashboard/spaces/video-123", - ); - expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard"); - }); }); diff --git a/apps/web/__tests__/unit/media-processing-workflows.test.ts b/apps/web/__tests__/unit/media-processing-workflows.test.ts index 57deff5af8c..a0ebd149d9d 100644 --- a/apps/web/__tests__/unit/media-processing-workflows.test.ts +++ b/apps/web/__tests__/unit/media-processing-workflows.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ remove: vi.fn(), fetch: vi.fn(), sleep: vi.fn(), + continueLoomImport: vi.fn(), })); vi.mock("@cap/database", () => ({ @@ -56,6 +57,9 @@ vi.mock("@/lib/video-storage", () => ({ decodeStorageVideo: (value: unknown) => value, })); vi.mock("@/lib/transcribe", () => ({ transcribeVideo: vi.fn() })); +vi.mock("@/lib/loom-import/dispatch", () => ({ + dispatchLoomImportForVideo: mocks.continueLoomImport, +})); vi.mock("@/lib/ai-generation-entitlement", () => ({ isAiGenerationEnabledForUser: () => false, })); @@ -111,6 +115,7 @@ describe("media processing workflows", () => { ); mocks.remove.mockImplementation(() => Effect.void); mocks.sleep.mockResolvedValue(undefined); + mocks.continueLoomImport.mockReset().mockResolvedValue(null); vi.stubGlobal("fetch", mocks.fetch); mocks.fetch.mockReset().mockImplementation(async (url: string) => { if (url.startsWith("https://www.loom.com/")) { @@ -198,6 +203,21 @@ describe("media processing workflows", () => { priority: "bulk", }); expect(mocks.remove).not.toHaveBeenCalled(); + expect(mocks.continueLoomImport).toHaveBeenCalledExactlyOnceWith("video"); + }); + + it("frees its import slot when a Loom video fails for good", async () => { + mocks.continueLoomImport.mockRejectedValueOnce(new Error("Database busy")); + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.fetch.mockImplementation(async (url: string) => + url.startsWith("https://www.loom.com/") + ? new Response(null, { status: 404 }) + : Response.json({ jobId: "job-1" }), + ); + await expect(importLoomVideoWorkflow(payload)).rejects.toThrow( + "Could not retrieve a download URL from Loom", + ); + expect(mocks.continueLoomImport).toHaveBeenCalledExactlyOnceWith("video"); }); it("reuses a preserved Loom upload when retrying", async () => { diff --git a/apps/web/actions/loom-import.ts b/apps/web/actions/loom-import.ts new file mode 100644 index 00000000000..999629875a6 --- /dev/null +++ b/apps/web/actions/loom-import.ts @@ -0,0 +1,101 @@ +"use server"; + +import { getCurrentUser } from "@cap/database/auth/session"; +import { userIsPro } from "@cap/utils"; +import type { Organisation } from "@cap/web-domain"; +import { start } from "workflow/api"; +import { + cancelLoomImportJob, + createLoomImportJob, + getLoomImportJobForUser, + LoomImportError, + markLoomImportJobStarting, + resetFailedLoomImportItems, +} from "@/lib/loom-import/jobs"; +import { loomImportJobWorkflow } from "@/workflows/loom-import-job"; + +type ActionResult> = + | ({ ok: true } & T) + | { ok: false; error: string }; + +const GENERIC_ERROR = "Something went wrong. Please try again."; + +export async function createLoomImportJobAction({ + orgId, + fileName, + rows, +}: { + orgId: Organisation.OrganisationId; + fileName: string; + rows: unknown; +}): Promise> { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Please sign in again." }; + + try { + const { jobId } = await createLoomImportJob({ + userId: user.id, + orgId, + fileName: typeof fileName === "string" ? fileName : "", + rows, + }); + await start(loomImportJobWorkflow, [{ jobId }]); + return { ok: true, jobId }; + } catch (error) { + if (error instanceof LoomImportError) + return { ok: false, error: error.message }; + console.error("[loom-import] Could not create import", error); + return { ok: false, error: GENERIC_ERROR }; + } +} + +export async function startLoomImportJobAction( + jobId: string, +): Promise> { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Please sign in again." }; + const found = await getLoomImportJobForUser(jobId, user.id); + if (!found) return { ok: false, error: "This import doesn't exist." }; + if (found.job.createdById !== user.id) { + return { + ok: false, + error: "Only the person who uploaded this CSV can start it.", + }; + } + if (!userIsPro(user)) { + return { ok: false, error: "Importing from Loom needs Cap Pro." }; + } + + const started = await markLoomImportJobStarting(jobId); + if (started) await start(loomImportJobWorkflow, [{ jobId }]); + return { ok: true, started }; +} + +export async function cancelLoomImportJobAction( + jobId: string, +): Promise { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Please sign in again." }; + const found = await getLoomImportJobForUser(jobId, user.id); + if (!found) return { ok: false, error: "This import doesn't exist." }; + await cancelLoomImportJob(jobId); + return { ok: true }; +} + +export async function retryLoomImportJobAction( + jobId: string, +): Promise> { + const user = await getCurrentUser(); + if (!user) return { ok: false, error: "Please sign in again." }; + const found = await getLoomImportJobForUser(jobId, user.id); + if (!found) return { ok: false, error: "This import doesn't exist." }; + if ( + found.job.status === "cancelled" || + found.job.status === "awaiting_upgrade" + ) { + return { ok: false, error: "This import can't be retried." }; + } + const retried = await resetFailedLoomImportItems(jobId); + if (retried > 0) await start(loomImportJobWorkflow, [{ jobId }]); + return { ok: true, retried }; +} diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 80386e189c7..8e4e46d9d83 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -7,12 +7,8 @@ import { nanoId } from "@cap/database/helpers"; import { folders, importedVideos, - organizationMembers, sharedVideos, - spaceMembers, - spaces, spaceVideos, - users, videos, videoUploads, } from "@cap/database/schema"; @@ -22,8 +18,7 @@ import { dub, userIsPro } from "@cap/utils"; import { Storage } from "@cap/web-backend"; import { type Organisation, - Space, - SpaceMemberId, + type Space, type User, Video, } from "@cap/web-domain"; @@ -32,14 +27,11 @@ import { Option } from "effect"; import { revalidatePath } from "next/cache"; import { start } from "workflow/api"; import { - getOrganizationAccess, requireOrganizationAccess, requireOrganizationSettingsManager, } from "@/actions/organization/authorization"; import { requireSpaceManager } from "@/actions/organization/space-authorization"; import type { LoomImportDestination } from "@/lib/loom-import-destination"; -import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; -import { canManageOrganizationSettings } from "@/lib/permissions/roles"; import { runPromise } from "@/lib/server"; import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; @@ -68,36 +60,6 @@ export interface LoomImportResult { error?: string; } -export interface LoomCsvImportRow { - rowNumber: number; - loomUrl: string; - userEmail: string; - spaceName?: string; -} - -export interface LoomCsvImportRowResult { - rowNumber: number; - userEmail: string; - spaceName?: string; - success: boolean; - videoId?: Video.VideoId; - error?: string; -} - -export interface LoomCsvImportResult { - success: boolean; - importedCount: number; - failedCount: number; - results: LoomCsvImportRowResult[]; - error?: string; -} - -const MAX_LOOM_CSV_ROWS = 500; -const MAX_LOOM_SPACE_NAME_LENGTH = 255; -const LOOM_CSV_LIMIT_ERROR = `CSV imports are limited to ${MAX_LOOM_CSV_ROWS} rows at a time. Contact support to raise this limit.`; -const LOOM_CSV_PERMISSION_ERROR = - "Only organization admins and owners can import Loom videos from a CSV."; - function extractLoomVideoId(url: string): string | null { try { const parsed = new URL(url); @@ -112,6 +74,9 @@ function extractLoomVideoId(url: string): string | null { return null; } + const trailingLoomId = id.match(/([0-9a-f]{32})$/i)?.[1]; + if (trailingLoomId) return trailingLoomId.toLowerCase(); + return id.split("?")[0] ?? null; } catch { return null; @@ -159,7 +124,9 @@ async function fetchLoomEndpoint( } } -async function fetchVideoName(videoId: string): Promise { +async function fetchVideoDetails( + videoId: string, +): Promise<{ name: string | null; createdAt: string | null }> { try { const response = await fetch("https://www.loom.com/graphql", { method: "POST", @@ -173,7 +140,7 @@ async function fetchVideoName(videoId: string): Promise { variables: { videoId, password: null }, query: `query GetVideoName($videoId: ID!, $password: String) { getVideo(id: $videoId, password: $password) { - ... on RegularUserVideo { name } + ... on RegularUserVideo { name createdAt } ... on PrivateVideo { id } ... on VideoPasswordMissingOrIncorrect { id } } @@ -181,12 +148,18 @@ async function fetchVideoName(videoId: string): Promise { }), }); - if (!response.ok) return null; + if (!response.ok) return { name: null, createdAt: null }; const data = await response.json(); - return data?.data?.getVideo?.name ?? null; + const video = data?.data?.getVideo; + const createdAt = + typeof video?.createdAt === "string" && + !Number.isNaN(Date.parse(video.createdAt)) + ? new Date(video.createdAt).toISOString() + : null; + return { name: video?.name ?? null, createdAt }; } catch { - return null; + return { name: null, createdAt: null }; } } @@ -270,14 +243,14 @@ export async function downloadLoomVideo( }; } - const [videoName, oembedMeta] = await Promise.all([ - fetchVideoName(videoId), + const [details, oembedMeta] = await Promise.all([ + fetchVideoDetails(videoId), fetchLoomOEmbed(videoId), ]); return { success: true, videoId, - videoName: videoName ?? undefined, + videoName: details.name ?? undefined, downloadUrl, downloadMode: isDirectMp4Url(downloadUrl) ? "direct-download" @@ -364,10 +337,11 @@ async function importLoomVideoForOwner({ }; } - const [videoName, oembedMeta] = await Promise.all([ - fetchVideoName(loomVideoId), + const [details, oembedMeta] = await Promise.all([ + fetchVideoDetails(loomVideoId), fetchLoomOEmbed(loomVideoId), ]); + const videoName = details.name; const writableResult = await Storage.getWritableAccessForUser(ownerId, orgId) .pipe(runPromise) @@ -394,6 +368,7 @@ async function importLoomVideoForOwner({ const name = videoName || `Loom Import - ${new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`; + const rawFileKey = `${ownerId}/${videoId}/raw-upload.mp4`; await db().transaction(async (tx) => { await tx.insert(videos).values({ @@ -409,6 +384,9 @@ async function importLoomVideoForOwner({ ...(oembedMeta?.duration ? { duration: oembedMeta.duration } : {}), ...(oembedMeta?.width ? { width: oembedMeta.width } : {}), ...(oembedMeta?.height ? { height: oembedMeta.height } : {}), + ...(details.createdAt + ? { metadata: { customCreatedAt: details.createdAt } } + : {}), }); await tx.insert(videoUploads).values({ @@ -416,6 +394,7 @@ async function importLoomVideoForOwner({ phase: "uploading", processingProgress: 0, processingMessage: "Importing from Loom...", + rawFileKey, }); await tx.insert(importedVideos).values({ @@ -444,8 +423,6 @@ async function importLoomVideoForOwner({ } }); - const rawFileKey = `${ownerId}/${videoId}/raw-upload.mp4`; - if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { await dub() .links.create({ @@ -567,381 +544,3 @@ export async function importFromLoom({ destination: { folderId, spaceId }, }); } - -function normalizeImportEmail(email: string) { - return email.trim().toLowerCase(); -} - -function normalizeImportSpaceName(spaceName: string) { - return spaceName.trim().replace(/\s+/g, " "); -} - -function getSpaceNameCacheKey(spaceName: string) { - return normalizeImportSpaceName(spaceName).toLowerCase(); -} - -function isValidImportEmail(email: string) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -function isValidImportSpaceName(spaceName: string) { - return spaceName.length <= MAX_LOOM_SPACE_NAME_LENGTH; -} - -async function getOrganizationMemberByEmail( - orgId: Organisation.OrganisationId, - email: string, -) { - const [member] = await db() - .select({ - userId: organizationMembers.userId, - email: users.email, - }) - .from(organizationMembers) - .innerJoin(users, eq(organizationMembers.userId, users.id)) - .where( - and( - eq(organizationMembers.organizationId, orgId), - eq(users.email, email), - ), - ) - .limit(1); - - return member ?? null; -} - -type ImportSpaceCacheValue = { - id: Space.SpaceIdOrOrganisationId; - name: string; -}; - -async function getOrCreateImportSpace({ - orgId, - createdById, - name, - spaceCache, -}: { - orgId: Organisation.OrganisationId; - createdById: User.UserId; - name: string; - spaceCache: Map; -}) { - const normalizedName = normalizeImportSpaceName(name); - const cacheKey = getSpaceNameCacheKey(normalizedName); - const cached = spaceCache.get(cacheKey); - if (cached) return cached; - - const [existingSpace] = await db() - .select({ - id: spaces.id, - name: spaces.name, - }) - .from(spaces) - .where( - and(eq(spaces.organizationId, orgId), eq(spaces.name, normalizedName)), - ) - .limit(1); - - if (existingSpace) { - const value = { - id: existingSpace.id, - name: existingSpace.name, - }; - spaceCache.set(cacheKey, value); - return value; - } - - const spaceId = Space.SpaceId.make(nanoId()); - - await db().transaction(async (tx) => { - await tx.insert(spaces).values({ - id: spaceId, - name: normalizedName, - organizationId: orgId, - createdById, - iconUrl: null, - }); - - await tx.insert(spaceMembers).values({ - id: SpaceMemberId.make(nanoId()), - spaceId, - userId: createdById, - role: "admin", - }); - }); - - const value = { - id: spaceId, - name: normalizedName, - }; - spaceCache.set(cacheKey, value); - return value; -} - -async function addImportedVideoToSpace({ - videoId, - spaceId, - addedById, -}: { - videoId: Video.VideoId; - spaceId: Space.SpaceIdOrOrganisationId; - addedById: User.UserId; -}) { - const [existingSpaceVideo] = await db() - .select({ id: spaceVideos.id }) - .from(spaceVideos) - .where( - and(eq(spaceVideos.spaceId, spaceId), eq(spaceVideos.videoId, videoId)), - ) - .limit(1); - - if (existingSpaceVideo) return; - - await db().insert(spaceVideos).values({ - id: nanoId(), - spaceId, - videoId, - addedById, - }); -} - -async function addImportOwnerToSpace({ - spaceId, - userId, -}: { - spaceId: Space.SpaceIdOrOrganisationId; - userId: User.UserId; -}) { - const [existingSpaceMember] = await db() - .select({ id: spaceMembers.id }) - .from(spaceMembers) - .where( - and(eq(spaceMembers.spaceId, spaceId), eq(spaceMembers.userId, userId)), - ) - .limit(1); - - if (existingSpaceMember) return; - - await db() - .insert(spaceMembers) - .values({ - id: SpaceMemberId.make(nanoId()), - spaceId, - userId, - role: "member", - }); -} - -export async function importFromLoomCsv({ - rows, - orgId, -}: { - rows: LoomCsvImportRow[]; - orgId: Organisation.OrganisationId; -}): Promise { - const user = await getCurrentUser(); - if (!user) { - return { - success: false, - importedCount: 0, - failedCount: 0, - results: [], - error: "Unauthorized", - }; - } - - if (!userIsPro(user)) { - return { - success: false, - importedCount: 0, - failedCount: 0, - results: [], - error: "Importing from Loom requires a Cap Pro subscription.", - }; - } - - const access = await getOrganizationAccess(user.id, orgId); - if (!canManageOrganizationSettings(access?.role)) { - return { - success: false, - importedCount: 0, - failedCount: 0, - results: [], - error: LOOM_CSV_PERMISSION_ERROR, - }; - } - - const inputRows = Array.isArray(rows) ? rows : []; - const normalizedRows = inputRows - .map((row, index) => ({ - rowNumber: - Number.isInteger(row.rowNumber) && row.rowNumber > 0 - ? row.rowNumber - : index + 2, - loomUrl: typeof row.loomUrl === "string" ? row.loomUrl.trim() : "", - userEmail: - typeof row.userEmail === "string" - ? normalizeImportEmail(row.userEmail) - : "", - spaceName: - typeof row.spaceName === "string" - ? normalizeImportSpaceName(row.spaceName) - : "", - })) - .filter((row) => row.loomUrl || row.userEmail || row.spaceName); - - if (normalizedRows.length === 0) { - return { - success: false, - importedCount: 0, - failedCount: 0, - results: [], - error: "No rows found to import.", - }; - } - - if (normalizedRows.length > MAX_LOOM_CSV_ROWS) { - return { - success: false, - importedCount: 0, - failedCount: normalizedRows.length, - results: [], - error: LOOM_CSV_LIMIT_ERROR, - }; - } - - const results: LoomCsvImportRowResult[] = []; - const spaceCache = new Map(); - const touchedSpaceIds = new Set(); - - for (const row of normalizedRows) { - if (!row.loomUrl) { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName || undefined, - success: false, - error: "Missing Loom video URL.", - }); - continue; - } - - if (!isValidImportEmail(row.userEmail)) { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName || undefined, - success: false, - error: "Missing or invalid user email.", - }); - continue; - } - - if (!isValidImportSpaceName(row.spaceName)) { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName, - success: false, - error: `Space name must be ${MAX_LOOM_SPACE_NAME_LENGTH} characters or fewer.`, - }); - continue; - } - - let member = await getOrganizationMemberByEmail(orgId, row.userEmail); - - if (!member) { - try { - const provisionedMember = await provisionOrganizationInvitee({ - organizationId: orgId, - email: row.userEmail, - invitedByUserId: user.id, - role: "member", - }); - member = { - userId: provisionedMember.userId, - email: row.userEmail, - }; - } catch { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName || undefined, - success: false, - error: "Could not add this email to the organization.", - }); - continue; - } - } - - try { - const result = await importLoomVideoForOwner({ - loomUrl: row.loomUrl, - orgId, - ownerId: member.userId, - }); - - let spaceName = row.spaceName || undefined; - let spaceError: string | undefined; - if (result.success && result.videoId && row.spaceName) { - try { - const space = await getOrCreateImportSpace({ - orgId, - createdById: user.id, - name: row.spaceName, - spaceCache, - }); - await addImportedVideoToSpace({ - videoId: result.videoId, - spaceId: space.id, - addedById: user.id, - }); - await addImportOwnerToSpace({ - spaceId: space.id, - userId: member.userId, - }); - touchedSpaceIds.add(space.id); - spaceName = space.name; - } catch { - spaceError = "Import started, but it could not be added to a space."; - } - } - - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName, - success: result.success, - videoId: result.videoId, - error: result.error ?? spaceError, - }); - } catch { - results.push({ - rowNumber: row.rowNumber, - userEmail: row.userEmail, - spaceName: row.spaceName || undefined, - success: false, - error: "Failed to start this import.", - }); - } - } - - const importedCount = results.filter((result) => result.success).length; - const failedCount = results.length - importedCount; - - for (const spaceId of touchedSpaceIds) { - revalidatePath(`/dashboard/spaces/${spaceId}`); - } - - if (touchedSpaceIds.size > 0) { - revalidatePath("/dashboard"); - } - - return { - success: importedCount > 0, - importedCount, - failedCount, - results, - error: importedCount > 0 ? undefined : "No Loom videos were imported.", - }; -} diff --git a/apps/web/app/api/cron/recover-failed-video-processing/route.ts b/apps/web/app/api/cron/recover-failed-video-processing/route.ts index 7d314b24d1c..68fd3c42089 100644 --- a/apps/web/app/api/cron/recover-failed-video-processing/route.ts +++ b/apps/web/app/api/cron/recover-failed-video-processing/route.ts @@ -1,5 +1,6 @@ import { timingSafeEqual } from "node:crypto"; import { NextResponse } from "next/server"; +import { recoverLoomImportJobs } from "@/lib/loom-import/recovery"; import { cleanupExpiredMediaProcessingBudgets } from "@/lib/media-processing-budget"; import { recoverStalledVideoPipeline } from "@/lib/video-pipeline-recovery"; import { recoverFailedVideoProcessing } from "@/lib/video-processing-recovery"; @@ -25,19 +26,25 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const [summary, stalledPipeline, expiredBudgetsDeleted] = await Promise.all([ - recoverFailedVideoProcessing(), - recoverStalledVideoPipeline(), - cleanupExpiredMediaProcessingBudgets().catch((error: unknown) => { - console.error("Processing budget cleanup failed", error); - return null; - }), - ]); + const [summary, stalledPipeline, expiredBudgetsDeleted, loomImports] = + await Promise.all([ + recoverFailedVideoProcessing(), + recoverStalledVideoPipeline(), + cleanupExpiredMediaProcessingBudgets().catch((error: unknown) => { + console.error("Processing budget cleanup failed", error); + return null; + }), + recoverLoomImportJobs().catch((error: unknown) => { + console.error("Loom import recovery failed", error); + return null; + }), + ]); return NextResponse.json({ success: true, ...summary, stalledPipeline, expiredBudgetsDeleted, + loomImports, }); } diff --git a/apps/web/app/api/import/loom/jobs/route.ts b/apps/web/app/api/import/loom/jobs/route.ts new file mode 100644 index 00000000000..306f0ebbb4b --- /dev/null +++ b/apps/web/app/api/import/loom/jobs/route.ts @@ -0,0 +1,133 @@ +import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, +} from "@effect/platform"; +import { Effect, Layer, Schema } from "effect"; +import { getLoomImportSnapshot } from "@/lib/loom-import/snapshot"; +import { apiToHandler } from "@/lib/server"; + +export const dynamic = "force-dynamic"; + +const DisplayStatus = Schema.Literal( + "checking", + "ready", + "queued", + "importing", + "imported", + "failed", + "skipped", + "cancelled", +); + +const Item = Schema.Struct({ + id: Schema.String, + row: Schema.Number, + url: Schema.String, + loomId: Schema.NullOr(Schema.String), + title: Schema.NullOr(Schema.String), + email: Schema.NullOr(Schema.String), + space: Schema.NullOr(Schema.String), + status: DisplayStatus, + stage: Schema.optional( + Schema.Literal( + "starting", + "waiting", + "downloading", + "processing", + "finishing", + ), + ), + progress: Schema.optional(Schema.Number), + error: Schema.optional(Schema.String), + videoId: Schema.NullOr(Schema.String), + recordedAt: Schema.NullOr(Schema.String), + duration: Schema.NullOr(Schema.Number), + thumb: Schema.NullOr(Schema.String), + v: Schema.Number, +}); + +const Snapshot = Schema.Struct({ + job: Schema.Struct({ + id: Schema.String, + fileName: Schema.String, + status: Schema.Literal( + "checking", + "awaiting_upgrade", + "importing", + "completed", + "cancelled", + ), + totalCount: Schema.Number, + createdAt: Schema.String, + startedAt: Schema.NullOr(Schema.String), + completedAt: Schema.NullOr(Schema.String), + createdByMe: Schema.Boolean, + canStart: Schema.Boolean, + isPro: Schema.Boolean, + isAdmin: Schema.Boolean, + }), + counts: Schema.Struct({ + checking: Schema.Number, + ready: Schema.Number, + queued: Schema.Number, + importing: Schema.Number, + imported: Schema.Number, + failed: Schema.Number, + skipped: Schema.Number, + cancelled: Schema.Number, + total: Schema.Number, + }), + totalDuration: Schema.Number, + importedDuration: Schema.Number, + owners: Schema.Number, + items: Schema.Array(Item), + cursor: Schema.Number, + full: Schema.Boolean, +}); + +class Api extends HttpApi.make("LoomImportJobsApi").add( + HttpApiGroup.make("root").add( + HttpApiEndpoint.get("getJob")`/api/import/loom/jobs` + .setUrlParams( + Schema.Struct({ + jobId: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(64)), + since: Schema.optional(Schema.NumberFromString), + }), + ) + .addSuccess(Snapshot) + .addError(HttpApiError.NotFound) + .addError(HttpApiError.InternalServerError) + .middleware(HttpAuthMiddleware), + ), +) {} + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + handlers.handle("getJob", ({ urlParams }) => + Effect.gen(function* () { + const user = yield* CurrentUser; + const snapshot = yield* Effect.tryPromise({ + try: () => + getLoomImportSnapshot({ + jobId: urlParams.jobId, + userId: user.id, + since: urlParams.since, + }), + catch: () => new HttpApiError.InternalServerError(), + }); + if (!snapshot) return yield* new HttpApiError.NotFound(); + return snapshot; + }), + ), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const GET = handler; diff --git a/apps/web/lib/loom-import/csv.ts b/apps/web/lib/loom-import/csv.ts new file mode 100644 index 00000000000..12b08b8da5d --- /dev/null +++ b/apps/web/lib/loom-import/csv.ts @@ -0,0 +1,462 @@ +export const LOOM_IMPORT_MAX_ROWS = 2000; +export const LOOM_IMPORT_MAX_SPACE_NAME_LENGTH = 255; +export const LOOM_IMPORT_MAX_URL_LENGTH = 1024; + +export type CsvTable = { + headers: string[]; + rows: string[][]; + delimiter: string; + headerless: boolean; +}; + +export type LoomImportField = "loomUrl" | "ownerEmail" | "spaceName"; + +export type LoomImportMapping = Partial>; + +export type LoomImportRowInput = { + rowNumber: number; + loomUrl: string; + ownerEmail?: string; + spaceName?: string; +}; + +export type LoomImportRowIssue = { + rowNumber: number; + value: string; + reason: "not_loom" | "bad_email" | "space_too_long" | "duplicate"; + duplicateOf?: number; +}; + +export type LoomImportPlan = { + rows: LoomImportRowInput[]; + issues: LoomImportRowIssue[]; + owners: number; + spaces: number; + overLimit: boolean; +}; + +const LOOM_ID_PATTERN = /^[0-9a-f]{32}$/i; +const TRAILING_LOOM_ID_PATTERN = /([0-9a-f]{32})$/i; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const LOOM_LINK_PATTERN = + /https?:\/\/(?:[a-z0-9-]+\.)*loom\.com\/[^\s"'<>,;)]+/gi; +const CANDIDATE_DELIMITERS = [",", ";", "\t"] as const; + +const URL_HEADER_HINTS = [ + "loomvideourl", + "loomurl", + "loomlink", + "sharelink", + "shareurl", + "videourl", + "videolink", + "link", + "url", +]; +const ID_HEADER_HINTS = ["loomvideoid", "loomid", "videoid", "id"]; +const EMAIL_HEADER_HINTS = [ + "useremail", + "owneremail", + "creatoremail", + "memberemail", + "recordedby", + "email", +]; +const SPACE_HEADER_HINTS = ["spacename", "space", "loomspace", "capspace"]; + +export function normalizeHeader(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function detectDelimiter(text: string) { + const firstLineEnd = text.search(/\r?\n/); + const sample = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd); + let best: string = ","; + let bestCount = 0; + for (const delimiter of CANDIDATE_DELIMITERS) { + let count = 0; + let inQuotes = false; + for (let index = 0; index < sample.length; index++) { + const char = sample[index]; + if (char === '"') inQuotes = !inQuotes; + else if (char === delimiter && !inQuotes) count++; + } + if (count > bestCount) { + best = delimiter; + bestCount = count; + } + } + return best; +} + +export function parseCsvRecords(input: string, delimiter = ",") { + const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input; + const records: string[][] = []; + const length = text.length; + const delimiterCode = delimiter.charCodeAt(0); + let row: string[] = []; + let index = 0; + + const pushRow = () => { + records.push(row); + row = []; + }; + + while (index <= length) { + if (index === length) { + if (row.length > 0) pushRow(); + break; + } + + if (text.charCodeAt(index) === 34) { + let value = ""; + let segmentStart = index + 1; + let cursor = segmentStart; + let closed = false; + while (cursor < length) { + const quote = text.indexOf('"', cursor); + if (quote === -1) break; + if (text.charCodeAt(quote + 1) === 34) { + value += `${text.slice(segmentStart, quote)}"`; + cursor = quote + 2; + segmentStart = cursor; + continue; + } + value += text.slice(segmentStart, quote); + cursor = quote + 1; + closed = true; + break; + } + if (!closed) throw new Error("This CSV has an unclosed quoted field."); + let end = cursor; + while ( + end < length && + text.charCodeAt(end) !== delimiterCode && + text.charCodeAt(end) !== 10 && + text.charCodeAt(end) !== 13 + ) { + end++; + } + row.push((value + text.slice(cursor, end)).trim()); + index = end; + } else { + let end = index; + while ( + end < length && + text.charCodeAt(end) !== delimiterCode && + text.charCodeAt(end) !== 10 && + text.charCodeAt(end) !== 13 + ) { + end++; + } + row.push(text.slice(index, end).trim()); + index = end; + } + + if (index >= length) { + pushRow(); + break; + } + + const code = text.charCodeAt(index); + if (code === delimiterCode) { + index++; + if (index === length) { + row.push(""); + pushRow(); + break; + } + continue; + } + + if (code === 13 && text.charCodeAt(index + 1) === 10) index += 2; + else index++; + pushRow(); + } + + while (records.length > 0 && !isFilledRecord(records[records.length - 1])) { + records.pop(); + } + return records; +} + +function isFilledRecord(record: string[] | undefined) { + return Boolean(record?.some((cell) => cell.length > 0)); +} + +export function countFilledRows(table: CsvTable) { + let count = 0; + for (const row of table.rows) if (isFilledRecord(row)) count++; + return count; +} + +export function parseCsv(text: string): CsvTable { + const delimiter = detectDelimiter(text); + const records = parseCsvRecords(text, delimiter); + const first = records[0]; + if (!first || !isFilledRecord(first)) throw new Error("This CSV is empty."); + const headerless = first.some( + (cell) => /loom\.com/i.test(cell) && extractLoomVideoId(cell) !== null, + ); + if (headerless) { + const width = records.reduce( + (max, record) => Math.max(max, record.length), + 0, + ); + return { + headers: Array.from( + { length: width }, + (_, index) => `Column ${index + 1}`, + ), + rows: records, + delimiter, + headerless: true, + }; + } + return { + headers: first.map((header) => header.trim()), + rows: records.slice(1), + delimiter, + headerless: false, + }; +} + +export function extractLoomVideoId(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (LOOM_ID_PATTERN.test(trimmed)) return trimmed.toLowerCase(); + + let url: URL; + try { + url = new URL( + /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, + ); + } catch { + return null; + } + + const host = url.hostname.toLowerCase(); + if (host !== "loom.com" && !host.endsWith(".loom.com")) return null; + + const segments = url.pathname.split("/").filter(Boolean); + const last = segments.at(-1); + if (!last || segments.length < 2) return null; + + const trailingId = last.match(TRAILING_LOOM_ID_PATTERN)?.[1]; + if (trailingId) return trailingId.toLowerCase(); + + return /^[a-z0-9]{10,64}$/i.test(last) ? last : null; +} + +export function loomShareUrl(loomVideoId: string) { + return `https://www.loom.com/share/${loomVideoId}`; +} + +export function isValidImportEmail(value: string) { + return EMAIL_PATTERN.test(value); +} + +export function normalizeImportEmail(value: string) { + return value.trim().toLowerCase(); +} + +export function normalizeImportSpaceName(value: string) { + return value.trim().replace(/\s+/g, " "); +} + +function sampleColumn(rows: string[][], column: number, limit = 60) { + const values: string[] = []; + for (const row of rows) { + const value = row[column]?.trim(); + if (value) values.push(value); + if (values.length >= limit) break; + } + return values; +} + +function headerScore(header: string, hints: string[]) { + const normalized = normalizeHeader(header); + const exact = hints.indexOf(normalized); + if (exact !== -1) return 1 - exact / (hints.length * 4); + const partial = hints.findIndex((hint) => normalized.includes(hint)); + return partial === -1 ? 0 : 0.5 - partial / (hints.length * 4); +} + +function bestColumn( + headers: string[], + score: (column: number) => number, + taken: Set, +) { + let best: number | undefined; + let bestScore = 0; + for (let column = 0; column < headers.length; column++) { + if (taken.has(column)) continue; + const value = score(column); + if (value > bestScore) { + best = column; + bestScore = value; + } + } + return best; +} + +export function detectLoomImportMapping(table: CsvTable): LoomImportMapping { + const { headers, rows } = table; + const taken = new Set(); + const samples = headers.map((_, column) => sampleColumn(rows, column)); + + const ratio = (column: number, test: (value: string) => boolean) => { + const values = samples[column] ?? []; + if (values.length === 0) return 0; + return values.filter(test).length / values.length; + }; + + const loomUrl = bestColumn( + headers, + (column) => { + const linkRatio = ratio(column, (value) => + /loom\.com/i.test(value) ? extractLoomVideoId(value) !== null : false, + ); + const idRatio = ratio(column, (value) => LOOM_ID_PATTERN.test(value)); + const header = headers[column] ?? ""; + if (linkRatio >= 0.5) + return 2 + linkRatio + headerScore(header, URL_HEADER_HINTS); + if (idRatio >= 0.5) + return 1 + idRatio + headerScore(header, ID_HEADER_HINTS); + return headerScore(header, URL_HEADER_HINTS) * 0.5; + }, + taken, + ); + if (loomUrl !== undefined) taken.add(loomUrl); + + const ownerEmail = bestColumn( + headers, + (column) => { + const emailRatio = ratio(column, isValidImportEmail); + const hint = headerScore(headers[column] ?? "", EMAIL_HEADER_HINTS); + return emailRatio >= 0.5 ? 1 + emailRatio + hint : 0; + }, + taken, + ); + if (ownerEmail !== undefined) taken.add(ownerEmail); + + const spaceName = bestColumn( + headers, + (column) => + SPACE_HEADER_HINTS.includes(normalizeHeader(headers[column] ?? "")) && + ratio(column, () => true) > 0 + ? 1 + : 0, + taken, + ); + + return { loomUrl, ownerEmail, spaceName }; +} + +export function tableFromPastedLinks(text: string): CsvTable { + const rows: string[][] = []; + for (const match of text.matchAll(LOOM_LINK_PATTERN)) { + rows.push([match[0].replace(/[.)\]]+$/, "")]); + } + if (rows.length === 0) { + for (const token of text.split(/\s+/)) { + if (LOOM_ID_PATTERN.test(token)) rows.push([token]); + } + } + return { headers: ["Loom link"], rows, delimiter: ",", headerless: true }; +} + +export function buildLoomImportPlan( + table: CsvTable, + mapping: LoomImportMapping, + { allowOwners }: { allowOwners: boolean }, +): LoomImportPlan { + const rows: LoomImportRowInput[] = []; + const issues: LoomImportRowIssue[] = []; + const seen = new Map(); + const owners = new Set(); + const spaces = new Set(); + const urlColumn = mapping.loomUrl; + + if (urlColumn === undefined) { + return { rows, issues, owners: 0, spaces: 0, overLimit: false }; + } + + const emailColumn = allowOwners ? mapping.ownerEmail : undefined; + const spaceColumn = allowOwners ? mapping.spaceName : undefined; + + const firstRowNumber = table.headerless ? 1 : 2; + table.rows.forEach((record, index) => { + const rowNumber = index + firstRowNumber; + const rawUrl = (record[urlColumn] ?? "").trim(); + const rawEmail = + emailColumn === undefined + ? "" + : normalizeImportEmail(record[emailColumn] ?? ""); + const rawSpace = + spaceColumn === undefined + ? "" + : normalizeImportSpaceName(record[spaceColumn] ?? ""); + + if (!rawUrl && !rawEmail && !rawSpace) return; + + const loomVideoId = + rawUrl.length <= LOOM_IMPORT_MAX_URL_LENGTH + ? extractLoomVideoId(rawUrl) + : null; + if (!loomVideoId) { + issues.push({ rowNumber, value: rawUrl, reason: "not_loom" }); + return; + } + if (rawEmail && !isValidImportEmail(rawEmail)) { + issues.push({ rowNumber, value: rawEmail, reason: "bad_email" }); + return; + } + if (rawSpace.length > LOOM_IMPORT_MAX_SPACE_NAME_LENGTH) { + issues.push({ rowNumber, value: rawSpace, reason: "space_too_long" }); + return; + } + const duplicateOf = seen.get(loomVideoId); + if (duplicateOf !== undefined) { + issues.push({ + rowNumber, + value: rawUrl, + reason: "duplicate", + duplicateOf, + }); + return; + } + seen.set(loomVideoId, rowNumber); + if (rawEmail) owners.add(rawEmail); + if (rawSpace) spaces.add(rawSpace.toLowerCase()); + rows.push({ + rowNumber, + loomUrl: /^https?:\/\//i.test(rawUrl) + ? rawUrl + : loomShareUrl(loomVideoId), + ...(rawEmail ? { ownerEmail: rawEmail } : {}), + ...(rawSpace ? { spaceName: rawSpace } : {}), + }); + }); + + return { + rows, + issues, + owners: owners.size, + spaces: spaces.size, + overLimit: rows.length > LOOM_IMPORT_MAX_ROWS, + }; +} + +export const LOOM_IMPORT_ISSUE_LABELS: Record< + LoomImportRowIssue["reason"], + string +> = { + not_loom: "Not a Loom link", + bad_email: "Owner email looks wrong", + space_too_long: "Space name is too long", + duplicate: "Duplicate video", +}; + +export const LOOM_CSV_TEMPLATE = + "loom_video_url,user_email,space_name\nhttps://www.loom.com/share/0123456789abcdef0123456789abcdef,teammate@example.com,Sales\n"; diff --git a/apps/web/lib/loom-import/dispatch.ts b/apps/web/lib/loom-import/dispatch.ts new file mode 100644 index 00000000000..a6132bdb403 --- /dev/null +++ b/apps/web/lib/loom-import/dispatch.ts @@ -0,0 +1,496 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + importedVideos, + loomImportJobItems, + loomImportJobs, + spaceVideos, + videos, + videoUploads, +} from "@cap/database/schema"; +import { getNewVideoPublic } from "@cap/database/video-sharing-default"; +import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; +import { dub } from "@cap/utils"; +import { Storage } from "@cap/web-backend/src/Storage/index"; +import { type Organisation, type User, Video } from "@cap/web-domain"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { Option } from "effect"; +import { start } from "workflow/api"; +import { runWorkflowPromise } from "@/lib/workflow-runtime"; +import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; + +const DEFAULT_CONCURRENCY = 4; +const MAX_CONCURRENCY = 32; +const WAITING_FOR_CAPACITY = "Queued for Loom import"; +const DELETED_VIDEO_ERROR = "The imported Cap was deleted."; + +type VideoInsert = typeof videos.$inferInsert; + +type Writable = { + bucketId: NonNullable | null; + storageIntegrationId: NonNullable | null; +}; + +type Launch = { + itemId: string; + videoId: Video.VideoId; + ownerId: User.UserId; + rawFileKey: string; + bucketId: string | null; + loomVideoId: string; + reuseExistingRawUpload: boolean; + created: boolean; +}; + +export function loomImportConcurrency() { + const configured = Number.parseInt( + serverEnv().LOOM_IMPORT_CONCURRENCY ?? "", + 10, + ); + return Number.isFinite(configured) && configured > 0 + ? Math.min(configured, MAX_CONCURRENCY) + : DEFAULT_CONCURRENCY; +} + +function isDuplicateKeyError(error: unknown): boolean { + let current: unknown = error; + for (let depth = 0; current && depth < 4; depth++) { + if ( + typeof current === "object" && + ("code" in current || "errno" in current) && + ((current as { code?: unknown }).code === "ER_DUP_ENTRY" || + (current as { errno?: unknown }).errno === 1062) + ) { + return true; + } + current = (current as { cause?: unknown }).cause; + } + return false; +} + +export function rawFileKeyFor(ownerId: string, videoId: string) { + return `${ownerId}/${videoId}/raw-upload.mp4`; +} + +async function writableFor( + cache: Map, + ownerId: User.UserId, + orgId: Organisation.OrganisationId, +) { + if (cache.has(ownerId)) return cache.get(ownerId) ?? null; + const writable = await Storage.getWritableAccessForUser(ownerId, orgId) + .pipe(runWorkflowPromise) + .then( + (value) => ({ + bucketId: Option.getOrNull(value.bucketId), + storageIntegrationId: Option.getOrNull(value.storageIntegrationId), + }), + (error) => { + console.error("[loom-import] Could not resolve storage", { + ownerId, + orgId, + error, + }); + return null; + }, + ); + cache.set(ownerId, writable); + return writable; +} + +export async function dispatchLoomImportJob(jobId: string) { + const [job] = await db() + .select({ orgId: loomImportJobs.orgId, status: loomImportJobs.status }) + .from(loomImportJobs) + .where(eq(loomImportJobs.id, jobId)) + .limit(1); + if (!job || job.status !== "importing") { + return { started: 0, completed: job?.status === "completed" }; + } + + const limit = loomImportConcurrency(); + const isPublic = await getNewVideoPublic(job.orgId); + const storage = new Map(); + + const outcome = await db().transaction(async (tx) => { + const [locked] = await tx + .select({ status: loomImportJobs.status }) + .from(loomImportJobs) + .where(eq(loomImportJobs.id, jobId)) + .for("update"); + if (!locked || locked.status !== "importing") { + return { launches: [] as Launch[], completed: false }; + } + + const inFlight = await tx + .select({ + id: loomImportJobItems.id, + videoId: loomImportJobItems.videoId, + videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number), + uploadVideoId: videoUploads.videoId, + uploadPhase: videoUploads.phase, + uploadMessage: videoUploads.processingMessage, + uploadError: videoUploads.processingError, + }) + .from(loomImportJobItems) + .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId)) + .leftJoin( + videoUploads, + eq(videoUploads.videoId, loomImportJobItems.videoId), + ) + .where( + and( + eq(loomImportJobItems.jobId, jobId), + eq(loomImportJobItems.status, "importing"), + ), + ); + + const now = new Date(); + const completedIds: string[] = []; + const failures = new Map(); + let active = 0; + let waiting = 0; + for (const row of inFlight) { + if (!row.videoId || !row.videoExists) { + failures.set(DELETED_VIDEO_ERROR, [ + ...(failures.get(DELETED_VIDEO_ERROR) ?? []), + row.id, + ]); + } else if (!row.uploadVideoId) { + completedIds.push(row.id); + } else if (row.uploadPhase === "error") { + const error = (row.uploadError || "Loom import failed.").slice(0, 512); + failures.set(error, [...(failures.get(error) ?? []), row.id]); + } else { + active++; + if (row.uploadMessage?.startsWith(WAITING_FOR_CAPACITY)) waiting++; + } + } + + if (completedIds.length > 0) { + await tx + .update(loomImportJobItems) + .set({ status: "complete", error: null, updatedAt: now }) + .where(inArray(loomImportJobItems.id, completedIds)); + } + for (const [error, ids] of failures) { + await tx + .update(loomImportJobItems) + .set({ status: "failed", error, updatedAt: now }) + .where(inArray(loomImportJobItems.id, ids)); + } + + const launches: Launch[] = []; + while (waiting === 0 && active + launches.length < limit) { + const claimed = await tx + .select({ + id: loomImportJobItems.id, + ownerId: loomImportJobItems.ownerId, + spaceId: loomImportJobItems.spaceId, + videoId: loomImportJobItems.videoId, + loomVideoId: loomImportJobItems.loomVideoId, + title: loomImportJobItems.title, + loomCreatedAt: loomImportJobItems.loomCreatedAt, + durationSeconds: loomImportJobItems.durationSeconds, + width: loomImportJobItems.width, + height: loomImportJobItems.height, + }) + .from(loomImportJobItems) + .where( + and( + eq(loomImportJobItems.jobId, jobId), + eq(loomImportJobItems.status, "ready"), + ), + ) + .orderBy(asc(loomImportJobItems.rowNumber)) + .limit(limit - active - launches.length) + .for("update"); + if (claimed.length === 0) break; + + for (const item of claimed) { + if (!item.ownerId || !item.loomVideoId) { + await tx + .update(loomImportJobItems) + .set({ + status: "failed", + error: "We couldn't find an owner for this video.", + updatedAt: now, + }) + .where(eq(loomImportJobItems.id, item.id)); + continue; + } + + if (item.videoId) { + const [existing] = await tx + .select({ + id: videos.id, + bucket: videos.bucket, + ownerId: videos.ownerId, + }) + .from(videos) + .where(eq(videos.id, item.videoId)) + .limit(1); + if (existing) { + const rawFileKey = rawFileKeyFor(existing.ownerId, existing.id); + await tx + .insert(videoUploads) + .values({ + videoId: existing.id, + phase: "uploading", + processingProgress: 0, + processingMessage: "Retrying Loom import...", + rawFileKey, + }) + .onDuplicateKeyUpdate({ + set: { + phase: "uploading", + processingProgress: 0, + processingMessage: "Retrying Loom import...", + processingError: null, + rawFileKey, + updatedAt: now, + }, + }); + await tx + .update(loomImportJobItems) + .set({ status: "importing", error: null, updatedAt: now }) + .where(eq(loomImportJobItems.id, item.id)); + launches.push({ + itemId: item.id, + videoId: existing.id, + ownerId: existing.ownerId, + rawFileKey, + bucketId: existing.bucket, + loomVideoId: item.loomVideoId, + reuseExistingRawUpload: true, + created: false, + }); + continue; + } + } + + const writable = await writableFor(storage, item.ownerId, job.orgId); + if (!writable) { + await tx + .update(loomImportJobItems) + .set({ + status: "failed", + error: "We couldn't prepare storage for this video.", + updatedAt: now, + }) + .where(eq(loomImportJobItems.id, item.id)); + continue; + } + + const videoId = Video.VideoId.make(nanoId()); + const ownerId = item.ownerId; + const loomVideoId = item.loomVideoId; + const rawFileKey = rawFileKeyFor(ownerId, videoId); + const fallbackName = `Loom Import - ${now.toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`; + try { + await tx.transaction(async (savepoint) => { + await savepoint.insert(videos).values({ + id: videoId, + name: item.title || fallbackName, + ownerId, + orgId: job.orgId, + source: { type: "webMP4" as const }, + bucket: writable.bucketId, + storageIntegrationId: writable.storageIntegrationId, + public: isPublic, + ...(item.durationSeconds + ? { duration: item.durationSeconds } + : {}), + ...(item.width ? { width: item.width } : {}), + ...(item.height ? { height: item.height } : {}), + ...(item.loomCreatedAt + ? { + metadata: { + customCreatedAt: item.loomCreatedAt.toISOString(), + }, + } + : {}), + }); + await savepoint.insert(videoUploads).values({ + videoId, + phase: "uploading", + processingProgress: 0, + processingMessage: "Importing from Loom...", + rawFileKey, + }); + await savepoint.insert(importedVideos).values({ + id: videoId, + orgId: job.orgId, + source: "loom", + sourceId: loomVideoId, + }); + if (item.spaceId) { + await savepoint.insert(spaceVideos).values({ + id: nanoId(), + spaceId: item.spaceId, + videoId, + addedById: ownerId, + }); + } + await savepoint + .update(loomImportJobItems) + .set({ + status: "importing", + videoId, + error: null, + updatedAt: now, + }) + .where(eq(loomImportJobItems.id, item.id)); + }); + } catch (error) { + if (!isDuplicateKeyError(error)) throw error; + const [existing] = await tx + .select({ id: importedVideos.id }) + .from(importedVideos) + .where( + and( + eq(importedVideos.orgId, job.orgId), + eq(importedVideos.source, "loom"), + eq(importedVideos.sourceId, loomVideoId), + ), + ) + .limit(1); + await tx + .update(loomImportJobItems) + .set({ + status: "skipped", + videoId: existing ? Video.VideoId.make(existing.id) : null, + error: "Already in Cap.", + updatedAt: now, + }) + .where(eq(loomImportJobItems.id, item.id)); + continue; + } + + launches.push({ + itemId: item.id, + videoId, + ownerId, + rawFileKey, + bucketId: writable.bucketId, + loomVideoId, + reuseExistingRawUpload: false, + created: true, + }); + } + } + + let completed = false; + if (active + launches.length === 0) { + const [open] = await tx + .select({ count: sql`COUNT(*)`.mapWith(Number) }) + .from(loomImportJobItems) + .where( + and( + eq(loomImportJobItems.jobId, jobId), + inArray(loomImportJobItems.status, [ + "pending", + "ready", + "importing", + ]), + ), + ); + completed = (open?.count ?? 0) === 0; + } + + await tx + .update(loomImportJobs) + .set( + completed + ? { status: "completed", completedAt: now, updatedAt: now } + : { updatedAt: now }, + ) + .where(eq(loomImportJobs.id, jobId)); + + return { launches, completed }; + }); + + for (const launch of outcome.launches) { + try { + await start(importLoomVideoWorkflow, [ + { + videoId: launch.videoId, + userId: launch.ownerId, + rawFileKey: launch.rawFileKey, + bucketId: launch.bucketId, + loomVideoId: launch.loomVideoId, + ...(launch.reuseExistingRawUpload + ? { reuseExistingRawUpload: true } + : {}), + }, + ]); + } catch (error) { + console.error("[loom-import] Could not start import workflow", { + videoId: launch.videoId, + error, + }); + const now = new Date(); + await db() + .update(videoUploads) + .set({ + phase: "error", + processingError: "Loom import could not start.", + processingMessage: "Loom import failed", + updatedAt: now, + }) + .where(eq(videoUploads.videoId, launch.videoId)); + await db() + .update(loomImportJobItems) + .set({ + status: "failed", + error: "Loom import could not start.", + updatedAt: now, + }) + .where(eq(loomImportJobItems.id, launch.itemId)); + } + } + + if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { + await Promise.all( + outcome.launches + .filter((launch) => launch.created) + .map((launch) => + dub() + .links.create({ + url: `${serverEnv().WEB_URL}/s/${launch.videoId}`, + domain: "cap.link", + key: launch.videoId, + }) + .catch(() => undefined), + ), + ); + } + + return { started: outcome.launches.length, completed: outcome.completed }; +} + +export async function dispatchLoomImportForVideo(videoId: string) { + const [item] = await db() + .select({ jobId: loomImportJobItems.jobId }) + .from(loomImportJobItems) + .where( + and( + eq(loomImportJobItems.videoId, Video.VideoId.make(videoId)), + eq(loomImportJobItems.status, "importing"), + ), + ) + .limit(1); + if (!item) return null; + return dispatchLoomImportJob(item.jobId); +} + +export async function isLoomImportedVideo(videoId: Video.VideoId) { + const [row] = await db() + .select({ id: importedVideos.id }) + .from(importedVideos) + .where( + and(eq(importedVideos.id, videoId), eq(importedVideos.source, "loom")), + ) + .limit(1); + return Boolean(row); +} diff --git a/apps/web/lib/loom-import/jobs.ts b/apps/web/lib/loom-import/jobs.ts new file mode 100644 index 00000000000..9d2f0c5c0b2 --- /dev/null +++ b/apps/web/lib/loom-import/jobs.ts @@ -0,0 +1,954 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + importedVideos, + type LoomImportJobStatus, + loomImportJobItems, + loomImportJobs, + organizationMembers, + spaceMembers, + spaces, + users, + videos, + videoUploads, +} from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; +import { + type Organisation, + Space, + SpaceMemberId, + User, + type Video, +} from "@cap/web-domain"; +import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; +import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; +import { canManageOrganizationSettings } from "@/lib/permissions/roles"; +import { + extractLoomVideoId, + isValidImportEmail, + LOOM_IMPORT_MAX_ROWS, + LOOM_IMPORT_MAX_SPACE_NAME_LENGTH, + LOOM_IMPORT_MAX_URL_LENGTH, + normalizeImportEmail, + normalizeImportSpaceName, +} from "./csv"; +import { + type LoomVideoLookup, + lookupLoomVideos, + loomLookupError, +} from "./loom-api"; + +const INSERT_CHUNK = 500; +const IN_CHUNK = 500; + +export class LoomImportError extends Error {} + +export const LOOM_IMPORT_LIMIT_MESSAGE = `Each CSV can hold up to ${LOOM_IMPORT_MAX_ROWS.toLocaleString("en-US")} videos. Split your list into more than one CSV and import them one after another.`; + +type ItemInsert = typeof loomImportJobItems.$inferInsert; + +function chunk(values: T[], size: number) { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; +} + +function truncate(value: string, length: number) { + return value.length > length ? value.slice(0, length) : value; +} + +export async function getLoomImportAccess( + userId: User.UserId, + orgId: Organisation.OrganisationId, +) { + const access = await getOrganizationAccess(userId, orgId); + if (!access) return null; + return { ...access, isAdmin: canManageOrganizationSettings(access.role) }; +} + +export async function getLoomImportJobForUser( + jobId: string, + userId: User.UserId, +) { + const [job] = await db() + .select() + .from(loomImportJobs) + .where(eq(loomImportJobs.id, jobId)) + .limit(1); + if (!job) return null; + const access = await getLoomImportAccess(userId, job.orgId); + if (!access) return null; + if (job.createdById !== userId && !access.isAdmin) return null; + return { job, isAdmin: access.isAdmin }; +} + +type IncomingRow = { + rowNumber?: unknown; + loomUrl?: unknown; + ownerEmail?: unknown; + spaceName?: unknown; +}; + +export function planLoomImportItems( + jobId: string, + rows: unknown, + { isAdmin }: { isAdmin: boolean }, +): ItemInsert[] { + if (!Array.isArray(rows)) throw new LoomImportError("No Loom links found."); + const seen = new Map(); + const usedRows = new Set(); + const items: ItemInsert[] = []; + const now = new Date(); + + (rows as IncomingRow[]).forEach((row, index) => { + const candidateRow = + typeof row?.rowNumber === "number" && + Number.isInteger(row.rowNumber) && + row.rowNumber > 0 + ? row.rowNumber + : index + 1; + const rowNumber = usedRows.has(candidateRow) + ? Math.max(...usedRows) + 1 + : candidateRow; + usedRows.add(rowNumber); + + const loomUrl = typeof row?.loomUrl === "string" ? row.loomUrl.trim() : ""; + const ownerEmail = + isAdmin && typeof row?.ownerEmail === "string" + ? normalizeImportEmail(row.ownerEmail) + : ""; + const spaceName = + isAdmin && typeof row?.spaceName === "string" + ? normalizeImportSpaceName(row.spaceName) + : ""; + + const base: ItemInsert = { + id: nanoId(), + jobId, + rowNumber, + loomUrl: truncate(loomUrl || "(empty)", LOOM_IMPORT_MAX_URL_LENGTH), + ownerEmail: ownerEmail ? truncate(ownerEmail, 255) : null, + spaceName: spaceName + ? truncate(spaceName, LOOM_IMPORT_MAX_SPACE_NAME_LENGTH) + : null, + status: "pending", + updatedAt: now, + }; + + const loomVideoId = + loomUrl.length <= LOOM_IMPORT_MAX_URL_LENGTH + ? extractLoomVideoId(loomUrl) + : null; + if (!loomVideoId) { + items.push({ + ...base, + status: "failed", + error: "This isn't a Loom link.", + }); + return; + } + if (ownerEmail && !isValidImportEmail(ownerEmail)) { + items.push({ + ...base, + loomVideoId, + status: "failed", + error: "The owner email looks wrong.", + }); + return; + } + if (spaceName.length > LOOM_IMPORT_MAX_SPACE_NAME_LENGTH) { + items.push({ + ...base, + loomVideoId, + status: "failed", + error: `Space names can be up to ${LOOM_IMPORT_MAX_SPACE_NAME_LENGTH} characters.`, + }); + return; + } + const duplicateOf = seen.get(loomVideoId); + if (duplicateOf !== undefined) { + items.push({ + ...base, + loomVideoId, + status: "skipped", + error: `Same video as row ${duplicateOf}.`, + }); + return; + } + seen.set(loomVideoId, rowNumber); + items.push({ ...base, loomVideoId }); + }); + + if (items.length === 0) throw new LoomImportError("No Loom links found."); + if (items.length > LOOM_IMPORT_MAX_ROWS) + throw new LoomImportError(LOOM_IMPORT_LIMIT_MESSAGE); + return items; +} + +export async function createLoomImportJob({ + userId, + orgId, + fileName, + rows, +}: { + userId: User.UserId; + orgId: Organisation.OrganisationId; + fileName: string; + rows: unknown; +}) { + const access = await getLoomImportAccess(userId, orgId); + if (!access) + throw new LoomImportError("You don't have access to this organization."); + + const jobId = nanoId(); + const items = planLoomImportItems(jobId, rows, { isAdmin: access.isAdmin }); + const now = new Date(); + const name = truncate(fileName.trim() || "Loom links", 255); + + await db().transaction(async (tx) => { + await tx.insert(loomImportJobs).values({ + id: jobId, + orgId, + createdById: userId, + fileName: name, + status: "checking", + totalCount: items.length, + createdAt: now, + updatedAt: now, + }); + for (const values of chunk(items, INSERT_CHUNK)) { + await tx.insert(loomImportJobItems).values(values); + } + }); + + return { jobId, totalCount: items.length }; +} + +type PendingItem = { + id: string; + jobId: string; + rowNumber: number; + loomUrl: string; + loomVideoId: string | null; +}; + +// MySQL applies these assignments in column order, so `status` may already hold +// the new value when the other columns are evaluated. Accept either state. +const whilePending = (column: string) => + sql.raw( + `IF(\`status\` IN ('pending', VALUES(\`status\`)), VALUES(\`${column}\`), \`${column}\`)`, + ); + +async function writeItemUpdates(rows: ItemInsert[]) { + if (rows.length === 0) return; + for (const values of chunk(rows, INSERT_CHUNK)) { + await db() + .insert(loomImportJobItems) + .values(values) + .onDuplicateKeyUpdate({ + set: { + title: whilePending("title"), + loomCreatedAt: whilePending("loom_created_at"), + durationSeconds: whilePending("duration_seconds"), + width: whilePending("width"), + height: whilePending("height"), + thumbnailUrl: whilePending("thumbnail_url"), + videoId: whilePending("video_id"), + error: whilePending("error"), + updatedAt: whilePending("updated_at"), + status: sql.raw( + "IF(`status` = 'pending', VALUES(`status`), `status`)", + ), + }, + }); + } +} + +function resolvedItem( + item: PendingItem, + lookup: LoomVideoLookup, + now: Date, +): ItemInsert { + const base: ItemInsert = { + id: item.id, + jobId: item.jobId, + rowNumber: item.rowNumber, + loomUrl: item.loomUrl, + updatedAt: now, + }; + if (lookup.status !== "ok") { + return { ...base, status: "failed", error: loomLookupError(lookup) }; + } + return { + ...base, + status: "ready", + title: lookup.title, + loomCreatedAt: lookup.createdAt ? new Date(lookup.createdAt) : null, + durationSeconds: lookup.durationSeconds, + width: lookup.width ? Math.round(lookup.width) : null, + height: lookup.height ? Math.round(lookup.height) : null, + thumbnailUrl: lookup.thumbnailUrl + ? truncate(lookup.thumbnailUrl, 1024) + : null, + error: null, + }; +} + +export async function resolveLoomImportJob( + jobId: string, + options: { fetchImpl?: typeof fetch } = {}, +) { + const [job] = await db() + .select({ orgId: loomImportJobs.orgId, status: loomImportJobs.status }) + .from(loomImportJobs) + .where(eq(loomImportJobs.id, jobId)) + .limit(1); + if (!job || job.status === "cancelled" || job.status === "completed") return; + + const pending: PendingItem[] = await db() + .select({ + id: loomImportJobItems.id, + jobId: loomImportJobItems.jobId, + rowNumber: loomImportJobItems.rowNumber, + loomUrl: loomImportJobItems.loomUrl, + loomVideoId: loomImportJobItems.loomVideoId, + }) + .from(loomImportJobItems) + .where( + and( + eq(loomImportJobItems.jobId, jobId), + eq(loomImportJobItems.status, "pending"), + ), + ) + .orderBy(asc(loomImportJobItems.rowNumber)); + if (pending.length === 0) return; + + const loomIds = Array.from( + new Set( + pending.flatMap((item) => (item.loomVideoId ? [item.loomVideoId] : [])), + ), + ); + const liveImports = new Map(); + const staleImportIds: string[] = []; + for (const ids of chunk(loomIds, IN_CHUNK)) { + const existing = await db() + .select({ + importId: importedVideos.id, + sourceId: importedVideos.sourceId, + videoId: videos.id, + }) + .from(importedVideos) + .leftJoin( + videos, + and( + eq(videos.id, importedVideos.id), + eq(videos.orgId, importedVideos.orgId), + ), + ) + .where( + and( + eq(importedVideos.orgId, job.orgId), + eq(importedVideos.source, "loom"), + inArray(importedVideos.sourceId, ids), + ), + ); + for (const row of existing) { + if (row.videoId) liveImports.set(row.sourceId, row.videoId); + else staleImportIds.push(row.importId); + } + } + + for (const ids of chunk(staleImportIds, IN_CHUNK)) { + await db() + .delete(importedVideos) + .where( + and( + eq(importedVideos.orgId, job.orgId), + eq(importedVideos.source, "loom"), + inArray(importedVideos.id, ids), + ), + ); + } + + const now = new Date(); + const skipped: ItemInsert[] = []; + const toLookup: PendingItem[] = []; + for (const item of pending) { + const existingVideo = item.loomVideoId + ? liveImports.get(item.loomVideoId) + : undefined; + if (existingVideo) { + skipped.push({ + id: item.id, + jobId: item.jobId, + rowNumber: item.rowNumber, + loomUrl: item.loomUrl, + status: "skipped", + videoId: existingVideo, + error: "Already in Cap.", + updatedAt: now, + }); + } else { + toLookup.push(item); + } + } + await writeItemUpdates(skipped); + + const itemsByLoomId = new Map(); + for (const item of toLookup) { + if (!item.loomVideoId) continue; + const list = itemsByLoomId.get(item.loomVideoId) ?? []; + list.push(item); + itemsByLoomId.set(item.loomVideoId, list); + } + + await lookupLoomVideos(Array.from(itemsByLoomId.keys()), { + fetchImpl: options.fetchImpl, + onBatch: async (results) => { + const updates: ItemInsert[] = []; + const batchTime = new Date(); + for (const [loomVideoId, lookup] of results) { + for (const item of itemsByLoomId.get(loomVideoId) ?? []) { + updates.push(resolvedItem(item, lookup, batchTime)); + } + } + await writeItemUpdates(updates); + }, + }); + + await db() + .update(loomImportJobs) + .set({ updatedAt: new Date() }) + .where(eq(loomImportJobs.id, jobId)); +} + +async function resolveImportOwners({ + orgId, + creator, + emails, +}: { + orgId: Organisation.OrganisationId; + creator: { id: User.UserId; email: string }; + emails: string[]; +}) { + const owners = new Map(); + owners.set(creator.email.toLowerCase(), creator.id); + const remaining = emails.filter((email) => !owners.has(email)); + + for (const batch of chunk(remaining, IN_CHUNK)) { + const members = await db() + .select({ userId: organizationMembers.userId, email: users.email }) + .from(organizationMembers) + .innerJoin(users, eq(organizationMembers.userId, users.id)) + .where( + and( + eq(organizationMembers.organizationId, orgId), + inArray(users.email, batch), + ), + ); + for (const member of members) { + owners.set(member.email.toLowerCase(), member.userId); + } + } + + const failed = new Set(); + for (const email of remaining) { + if (owners.has(email)) continue; + try { + const provisioned = await provisionOrganizationInvitee({ + organizationId: orgId, + email, + invitedByUserId: creator.id, + role: "member", + }); + owners.set(email, User.UserId.make(provisioned.userId)); + } catch { + failed.add(email); + } + } + return { owners, failed }; +} + +async function resolveImportSpaces({ + orgId, + creatorId, + names, +}: { + orgId: Organisation.OrganisationId; + creatorId: User.UserId; + names: string[]; +}) { + const byKey = new Map(); + if (names.length === 0) return byKey; + + const existing = await db() + .select({ id: spaces.id, name: spaces.name }) + .from(spaces) + .where(eq(spaces.organizationId, orgId)); + for (const space of existing) { + const key = normalizeImportSpaceName(space.name).toLowerCase(); + if (!byKey.has(key)) byKey.set(key, space.id); + } + + for (const name of names) { + const key = name.toLowerCase(); + if (byKey.has(key)) continue; + const spaceId = Space.SpaceId.make(nanoId()); + await db().transaction(async (tx) => { + await tx.insert(spaces).values({ + id: spaceId, + name, + organizationId: orgId, + createdById: creatorId, + iconUrl: null, + }); + await tx.insert(spaceMembers).values({ + id: SpaceMemberId.make(nanoId()), + spaceId, + userId: creatorId, + role: "admin", + }); + }); + byKey.set(key, spaceId); + } + return byKey; +} + +async function ensureSpaceMembers( + pairs: Map>, +) { + for (const [spaceId, userIds] of pairs) { + const ids = Array.from(userIds); + const existing = await db() + .select({ userId: spaceMembers.userId }) + .from(spaceMembers) + .where( + and( + eq(spaceMembers.spaceId, spaceId), + inArray(spaceMembers.userId, ids), + ), + ); + const present = new Set(existing.map((row) => row.userId)); + const missing = ids.filter((id) => !present.has(id)); + if (missing.length === 0) continue; + await db() + .insert(spaceMembers) + .values( + missing.map((userId) => ({ + id: SpaceMemberId.make(nanoId()), + spaceId, + userId, + role: "member" as const, + })), + ); + } +} + +export async function prepareLoomImportJob( + jobId: string, +): Promise { + const [job] = await db() + .select() + .from(loomImportJobs) + .where(eq(loomImportJobs.id, jobId)) + .limit(1); + if (!job) return null; + if (job.status === "cancelled" || job.status === "completed") + return job.status; + + const [creator] = await db() + .select() + .from(users) + .where(eq(users.id, job.createdById)) + .limit(1); + + if (!creator || !userIsPro(creator)) { + await db() + .update(loomImportJobs) + .set({ status: "awaiting_upgrade", updatedAt: new Date() }) + .where( + and( + eq(loomImportJobs.id, jobId), + inArray(loomImportJobs.status, ["checking", "importing"]), + ), + ); + return "awaiting_upgrade"; + } + + const access = await getLoomImportAccess(creator.id, job.orgId); + const unassigned = await db() + .select({ + id: loomImportJobItems.id, + ownerEmail: loomImportJobItems.ownerEmail, + spaceName: loomImportJobItems.spaceName, + }) + .from(loomImportJobItems) + .where( + and( + eq(loomImportJobItems.jobId, jobId), + eq(loomImportJobItems.status, "ready"), + isNull(loomImportJobItems.ownerId), + ), + ); + + if (!access) { + await db() + .update(loomImportJobItems) + .set({ + status: "failed", + error: "You no longer have access to this organization.", + updatedAt: new Date(), + }) + .where( + and( + eq(loomImportJobItems.jobId, jobId), + inArray(loomImportJobItems.status, ["pending", "ready"]), + ), + ); + await db() + .update(loomImportJobs) + .set({ + status: "completed", + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(loomImportJobs.id, jobId)); + return "completed"; + } + + if (unassigned.length > 0) { + const emails = access.isAdmin + ? Array.from( + new Set( + unassigned.flatMap((item) => + item.ownerEmail ? [item.ownerEmail.toLowerCase()] : [], + ), + ), + ) + : []; + const spaceNamesByKey = new Map(); + if (access.isAdmin) { + for (const item of unassigned) { + const key = item.spaceName?.toLowerCase(); + if (key && item.spaceName && !spaceNamesByKey.has(key)) { + spaceNamesByKey.set(key, item.spaceName); + } + } + } + const spaceNames = Array.from(spaceNamesByKey.values()); + + const { owners, failed } = await resolveImportOwners({ + orgId: job.orgId, + creator: { id: creator.id, email: creator.email }, + emails, + }); + const spaceByKey = await resolveImportSpaces({ + orgId: job.orgId, + creatorId: creator.id, + names: spaceNames, + }); + + const groups = new Map< + string, + { + ownerId: User.UserId; + spaceId: Space.SpaceIdOrOrganisationId | null; + ids: string[]; + } + >(); + const failedByEmail = new Map(); + const spacePairs = new Map< + Space.SpaceIdOrOrganisationId, + Set + >(); + + for (const item of unassigned) { + const email = + access.isAdmin && item.ownerEmail + ? item.ownerEmail.toLowerCase() + : null; + if (email && failed.has(email)) { + const list = failedByEmail.get(email) ?? []; + list.push(item.id); + failedByEmail.set(email, list); + continue; + } + const ownerId = email ? owners.get(email) : creator.id; + if (!ownerId) { + const list = failedByEmail.get(email ?? "") ?? []; + list.push(item.id); + failedByEmail.set(email ?? "", list); + continue; + } + const spaceId = + access.isAdmin && item.spaceName + ? (spaceByKey.get(item.spaceName.toLowerCase()) ?? null) + : null; + if (spaceId && ownerId !== creator.id) { + const members = spacePairs.get(spaceId) ?? new Set(); + members.add(ownerId); + spacePairs.set(spaceId, members); + } + const key = `${ownerId}|${spaceId ?? ""}`; + const group = groups.get(key) ?? { ownerId, spaceId, ids: [] }; + group.ids.push(item.id); + groups.set(key, group); + } + + await ensureSpaceMembers(spacePairs); + + const now = new Date(); + for (const group of groups.values()) { + for (const ids of chunk(group.ids, IN_CHUNK)) { + await db() + .update(loomImportJobItems) + .set({ + ownerId: group.ownerId, + spaceId: group.spaceId, + updatedAt: now, + }) + .where( + and( + inArray(loomImportJobItems.id, ids), + eq(loomImportJobItems.status, "ready"), + ), + ); + } + } + for (const [email, ids] of failedByEmail) { + for (const batch of chunk(ids, IN_CHUNK)) { + await db() + .update(loomImportJobItems) + .set({ + status: "failed", + error: email + ? `We couldn't add ${email} to your organization.` + : "We couldn't find an owner for this video.", + updatedAt: now, + }) + .where(inArray(loomImportJobItems.id, batch)); + } + } + } + + await db() + .update(loomImportJobs) + .set({ + status: "importing", + startedAt: job.startedAt ?? new Date(), + completedAt: null, + updatedAt: new Date(), + }) + .where( + and( + eq(loomImportJobs.id, jobId), + inArray(loomImportJobs.status, [ + "checking", + "awaiting_upgrade", + "importing", + ]), + ), + ); + return "importing"; +} + +export async function markLoomImportJobStarting(jobId: string) { + const result = await db() + .update(loomImportJobs) + .set({ status: "checking", updatedAt: new Date() }) + .where( + and( + eq(loomImportJobs.id, jobId), + eq(loomImportJobs.status, "awaiting_upgrade"), + ), + ); + return affectedRows(result) > 0; +} + +export async function cancelLoomImportJob(jobId: string) { + await db().transaction(async (tx) => { + const [job] = await tx + .select({ status: loomImportJobs.status }) + .from(loomImportJobs) + .where(eq(loomImportJobs.id, jobId)) + .for("update"); + if (!job || job.status === "completed" || job.status === "cancelled") + return; + const now = new Date(); + await tx + .update(loomImportJobItems) + .set({ status: "cancelled", updatedAt: now }) + .where( + and( + eq(loomImportJobItems.jobId, jobId), + inArray(loomImportJobItems.status, ["pending", "ready"]), + ), + ); + await tx + .update(loomImportJobs) + .set({ status: "cancelled", completedAt: now, updatedAt: now }) + .where(eq(loomImportJobs.id, jobId)); + }); +} + +export async function resetFailedLoomImportItems(jobId: string) { + const failed = await db() + .select({ + id: loomImportJobItems.id, + videoId: loomImportJobItems.videoId, + loomVideoId: loomImportJobItems.loomVideoId, + videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number), + uploadPhase: videoUploads.phase, + uploadVideoId: videoUploads.videoId, + }) + .from(loomImportJobItems) + .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId)) + .leftJoin( + videoUploads, + eq(videoUploads.videoId, loomImportJobItems.videoId), + ) + .where( + and( + eq(loomImportJobItems.jobId, jobId), + inArray(loomImportJobItems.status, ["failed", "importing", "complete"]), + ), + ); + + const toPending: string[] = []; + const toReady: string[] = []; + for (const item of failed) { + if (!item.loomVideoId) continue; + if (!item.videoId || !item.videoExists) { + toPending.push(item.id); + } else if (item.uploadPhase === "error") { + toReady.push(item.id); + } + } + + const now = new Date(); + for (const ids of chunk(toPending, IN_CHUNK)) { + await db() + .update(loomImportJobItems) + .set({ + status: "pending", + videoId: null, + ownerId: null, + spaceId: null, + error: null, + updatedAt: now, + }) + .where(inArray(loomImportJobItems.id, ids)); + } + for (const ids of chunk(toReady, IN_CHUNK)) { + await db() + .update(loomImportJobItems) + .set({ status: "ready", error: null, updatedAt: now }) + .where(inArray(loomImportJobItems.id, ids)); + } + + const retried = toPending.length + toReady.length; + if (retried > 0) { + await db() + .update(loomImportJobs) + .set({ + status: "checking", + completedAt: null, + updatedAt: now, + }) + .where( + and( + eq(loomImportJobs.id, jobId), + inArray(loomImportJobs.status, ["importing", "completed"]), + ), + ); + } + return retried; +} + +export type LoomImportJobSummary = { + id: string; + fileName: string; + status: LoomImportJobStatus; + totalCount: number; + createdAt: string; + completedAt: string | null; + imported: number; + failed: number; + settled: number; +}; + +export async function listLoomImportJobs({ + userId, + orgId, + limit = 8, +}: { + userId: User.UserId; + orgId: Organisation.OrganisationId; + limit?: number; +}): Promise { + const jobs = await db() + .select() + .from(loomImportJobs) + .where( + and( + eq(loomImportJobs.orgId, orgId), + eq(loomImportJobs.createdById, userId), + ), + ) + .orderBy(desc(loomImportJobs.createdAt)) + .limit(limit); + if (jobs.length === 0) return []; + + const counts = await db() + .select({ + jobId: loomImportJobItems.jobId, + status: loomImportJobItems.status, + count: sql`COUNT(*)`.mapWith(Number), + }) + .from(loomImportJobItems) + .where( + inArray( + loomImportJobItems.jobId, + jobs.map((job) => job.id), + ), + ) + .groupBy(loomImportJobItems.jobId, loomImportJobItems.status); + + return jobs.map((job) => { + const byStatus = new Map( + counts + .filter((row) => row.jobId === job.id) + .map((row) => [row.status, row.count] as const), + ); + const imported = byStatus.get("complete") ?? 0; + const failed = byStatus.get("failed") ?? 0; + const settled = + imported + + failed + + (byStatus.get("skipped") ?? 0) + + (byStatus.get("cancelled") ?? 0); + return { + id: job.id, + fileName: job.fileName, + status: job.status, + totalCount: job.totalCount, + createdAt: job.createdAt.toISOString(), + completedAt: job.completedAt?.toISOString() ?? null, + imported, + failed, + settled, + }; + }); +} + +export function affectedRows(result: unknown) { + if (Array.isArray(result)) { + return ( + (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0 + ); + } + return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0; +} diff --git a/apps/web/lib/loom-import/loom-api.ts b/apps/web/lib/loom-import/loom-api.ts new file mode 100644 index 00000000000..4475a0b2a81 --- /dev/null +++ b/apps/web/lib/loom-import/loom-api.ts @@ -0,0 +1,189 @@ +export type LoomVideoLookup = + | { + status: "ok"; + title: string | null; + createdAt: string | null; + durationSeconds: number | null; + width: number | null; + height: number | null; + thumbnailUrl: string | null; + } + | { status: "private" } + | { status: "password" } + | { status: "not_found" } + | { status: "error" }; + +type LookupOptions = { + batchSize?: number; + concurrency?: number; + attempts?: number; + fetchImpl?: typeof fetch; + onBatch?: (results: Map) => Promise | void; +}; + +type GraphqlVideo = { + __typename?: string; + name?: string | null; + createdAt?: string | null; + thumbnails?: { default?: string | null } | null; + video_properties?: { + duration?: number | null; + width?: number | null; + height?: number | null; + } | null; +} | null; + +const LOOM_GRAPHQL_URL = "https://www.loom.com/graphql"; +const VIDEO_FIELDS = `__typename +... on RegularUserVideo { name createdAt thumbnails { default } video_properties { duration width height } } +... on PrivateVideo { id } +... on VideoPasswordMissingOrIncorrect { id }`; + +function positiveNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : null; +} + +function validIsoDate(value: unknown) { + if (typeof value !== "string") return null; + return Number.isNaN(Date.parse(value)) ? null : value; +} + +function httpsUrl(value: unknown) { + if (typeof value !== "string") return null; + try { + return new URL(value).protocol === "https:" ? value : null; + } catch { + return null; + } +} + +export function toLoomVideoLookup(video: GraphqlVideo): LoomVideoLookup { + if (!video) return { status: "not_found" }; + switch (video.__typename) { + case "RegularUserVideo": + return { + status: "ok", + title: video.name?.trim().slice(0, 255) || null, + createdAt: validIsoDate(video.createdAt), + durationSeconds: positiveNumber(video.video_properties?.duration), + width: positiveNumber(video.video_properties?.width), + height: positiveNumber(video.video_properties?.height), + thumbnailUrl: httpsUrl(video.thumbnails?.default), + }; + case "PrivateVideo": + return { status: "private" }; + case "VideoPasswordMissingOrIncorrect": + return { status: "password" }; + default: + return { status: "not_found" }; + } +} + +export function buildLoomVideosQuery(ids: string[]) { + const variables = ids.map((_, index) => `$id${index}: ID!`).join(", "); + const selections = ids + .map( + (_, index) => + `v${index}: getVideo(id: $id${index}, password: null) { ${VIDEO_FIELDS} }`, + ) + .join("\n"); + return { + operationName: "CapImportVideos", + query: `query CapImportVideos(${variables}) {\n${selections}\n}`, + variables: Object.fromEntries(ids.map((id, index) => [`id${index}`, id])), + }; +} + +async function lookupBatch( + ids: string[], + fetchImpl: typeof fetch, + attempts: number, +): Promise> { + const body = JSON.stringify(buildLoomVideosQuery(ids)); + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const response = await fetchImpl(LOOM_GRAPHQL_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-loom-request-source": "loom_web", + }, + body, + signal: AbortSignal.timeout(15_000), + }); + if (response.ok) { + const json = (await response.json()) as { + data?: Record | null; + }; + if (json.data) { + const results = new Map(); + ids.forEach((id, index) => { + results.set( + id, + toLoomVideoLookup(json.data?.[`v${index}`] ?? null), + ); + }); + return results; + } + } else if (response.status < 500 && response.status !== 429) { + break; + } + } catch {} + if (attempt < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 400 * 2 ** attempt)); + } + } + return new Map(ids.map((id) => [id, { status: "error" } as const])); +} + +export async function lookupLoomVideos( + ids: string[], + { + batchSize = 25, + concurrency = 4, + attempts = 3, + fetchImpl = fetch, + onBatch, + }: LookupOptions = {}, +): Promise> { + const unique = Array.from(new Set(ids)); + const batches: string[][] = []; + for (let index = 0; index < unique.length; index += batchSize) { + batches.push(unique.slice(index, index + batchSize)); + } + + const results = new Map(); + let next = 0; + const workers = Array.from( + { length: Math.min(concurrency, batches.length) }, + async () => { + while (next < batches.length) { + const batch = batches[next++]; + if (!batch) continue; + const batchResults = await lookupBatch(batch, fetchImpl, attempts); + for (const [id, lookup] of batchResults) results.set(id, lookup); + await onBatch?.(batchResults); + } + }, + ); + await Promise.all(workers); + return results; +} + +export function loomLookupError(lookup: LoomVideoLookup) { + switch (lookup.status) { + case "private": + return "This Loom is private. Ask the owner to share it with anyone who has the link."; + case "password": + return "This Loom is password protected."; + case "not_found": + return "We couldn't find this Loom. It may have been deleted."; + case "error": + return "Loom didn't respond. Try again in a minute."; + default: + return null; + } +} diff --git a/apps/web/lib/loom-import/recovery.ts b/apps/web/lib/loom-import/recovery.ts new file mode 100644 index 00000000000..a640491ed4a --- /dev/null +++ b/apps/web/lib/loom-import/recovery.ts @@ -0,0 +1,110 @@ +import { db } from "@cap/database"; +import { + loomImportJobItems, + loomImportJobs, + videos, + videoUploads, +} from "@cap/database/schema"; +import { and, eq, lt } from "drizzle-orm"; +import { start } from "workflow/api"; +import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; +import { loomImportJobWorkflow } from "@/workflows/loom-import-job"; +import { dispatchLoomImportJob, rawFileKeyFor } from "./dispatch"; + +const STALE_CHECKING_MS = 10 * 60 * 1000; +const STALE_IMPORTING_MS = 2 * 60 * 1000; +const STUCK_START_MS = 30 * 60 * 1000; + +export async function recoverLoomImportJobs(now = new Date(), limit = 20) { + const staleChecking = await db() + .select({ id: loomImportJobs.id }) + .from(loomImportJobs) + .where( + and( + eq(loomImportJobs.status, "checking"), + lt( + loomImportJobs.updatedAt, + new Date(now.getTime() - STALE_CHECKING_MS), + ), + ), + ) + .limit(limit); + for (const job of staleChecking) { + await db() + .update(loomImportJobs) + .set({ updatedAt: now }) + .where(eq(loomImportJobs.id, job.id)); + await start(loomImportJobWorkflow, [{ jobId: job.id }]); + } + + const staleImporting = await db() + .select({ id: loomImportJobs.id }) + .from(loomImportJobs) + .where( + and( + eq(loomImportJobs.status, "importing"), + lt( + loomImportJobs.updatedAt, + new Date(now.getTime() - STALE_IMPORTING_MS), + ), + ), + ) + .limit(limit); + + let restarted = 0; + for (const job of staleImporting) { + const stuck = await db() + .select({ + videoId: videos.id, + ownerId: videos.ownerId, + bucket: videos.bucket, + loomVideoId: loomImportJobItems.loomVideoId, + rawFileKey: videoUploads.rawFileKey, + }) + .from(loomImportJobItems) + .innerJoin(videos, eq(videos.id, loomImportJobItems.videoId)) + .innerJoin( + videoUploads, + eq(videoUploads.videoId, loomImportJobItems.videoId), + ) + .where( + and( + eq(loomImportJobItems.jobId, job.id), + eq(loomImportJobItems.status, "importing"), + eq(videoUploads.phase, "uploading"), + lt(videoUploads.updatedAt, new Date(now.getTime() - STUCK_START_MS)), + ), + ); + for (const row of stuck) { + if (!row.loomVideoId) continue; + const rawFileKey = + row.rawFileKey ?? rawFileKeyFor(row.ownerId, row.videoId); + await db() + .update(videoUploads) + .set({ + processingMessage: "Retrying Loom import...", + rawFileKey, + updatedAt: now, + }) + .where(eq(videoUploads.videoId, row.videoId)); + await start(importLoomVideoWorkflow, [ + { + videoId: row.videoId, + userId: row.ownerId, + rawFileKey, + bucketId: row.bucket, + loomVideoId: row.loomVideoId, + reuseExistingRawUpload: true, + }, + ]); + restarted++; + } + await dispatchLoomImportJob(job.id); + } + + return { + checking: staleChecking.length, + importing: staleImporting.length, + restarted, + }; +} diff --git a/apps/web/lib/loom-import/report.ts b/apps/web/lib/loom-import/report.ts new file mode 100644 index 00000000000..461ab76d76d --- /dev/null +++ b/apps/web/lib/loom-import/report.ts @@ -0,0 +1,47 @@ +import type { LoomImportItemView } from "./status"; + +const COLUMNS = [ + "row", + "loom_url", + "title", + "recorded_at", + "owner_email", + "space", + "status", + "cap_url", + "note", +] as const; + +function escapeCell(value: string) { + const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; + return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe; +} + +export function buildLoomImportReport( + items: readonly LoomImportItemView[], + origin: string, +) { + const lines = [COLUMNS.join(",")]; + for (const item of items) { + const capUrl = + item.videoId && (item.status === "imported" || item.status === "skipped") + ? `${origin}/s/${item.videoId}` + : ""; + lines.push( + [ + String(item.row), + item.url, + item.title ?? "", + item.recordedAt ?? "", + item.email ?? "", + item.space ?? "", + item.status, + capUrl, + item.error ?? "", + ] + .map(escapeCell) + .join(","), + ); + } + return `${lines.join("\n")}\n`; +} diff --git a/apps/web/lib/loom-import/snapshot.ts b/apps/web/lib/loom-import/snapshot.ts new file mode 100644 index 00000000000..c12b44cad24 --- /dev/null +++ b/apps/web/lib/loom-import/snapshot.ts @@ -0,0 +1,161 @@ +import { db } from "@cap/database"; +import { + loomImportJobItems, + users, + videos, + videoUploads, +} from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; +import type { User } from "@cap/web-domain"; +import { asc, eq, sql } from "drizzle-orm"; +import { getLoomImportJobForUser } from "./jobs"; +import { + countLoomImportItems, + deriveLoomImportItemState, + type LoomImportItemView, + type LoomImportSnapshot, +} from "./status"; + +const CURSOR_OVERLAP_MS = 5_000; + +function time(value: Date | null | undefined) { + return value ? value.getTime() : 0; +} + +export async function getLoomImportSnapshot({ + jobId, + userId, + since, +}: { + jobId: string; + userId: User.UserId; + since?: number; +}): Promise { + const found = await getLoomImportJobForUser(jobId, userId); + if (!found) return null; + const { job, isAdmin } = found; + + const [[viewer], rows] = await Promise.all([ + db() + .select({ + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: users.thirdPartyStripeSubscriptionId, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1), + db() + .select({ + id: loomImportJobItems.id, + rowNumber: loomImportJobItems.rowNumber, + loomUrl: loomImportJobItems.loomUrl, + loomVideoId: loomImportJobItems.loomVideoId, + ownerEmail: loomImportJobItems.ownerEmail, + spaceName: loomImportJobItems.spaceName, + status: loomImportJobItems.status, + videoId: loomImportJobItems.videoId, + title: loomImportJobItems.title, + loomCreatedAt: loomImportJobItems.loomCreatedAt, + durationSeconds: loomImportJobItems.durationSeconds, + thumbnailUrl: loomImportJobItems.thumbnailUrl, + error: loomImportJobItems.error, + updatedAt: loomImportJobItems.updatedAt, + videoExists: sql`${videos.id} IS NOT NULL`.mapWith(Number), + videoUpdatedAt: videos.updatedAt, + uploadPhase: videoUploads.phase, + uploadProgress: videoUploads.processingProgress, + uploadMessage: videoUploads.processingMessage, + uploadError: videoUploads.processingError, + uploadUpdatedAt: videoUploads.updatedAt, + }) + .from(loomImportJobItems) + .leftJoin(videos, eq(videos.id, loomImportJobItems.videoId)) + .leftJoin( + videoUploads, + eq(videoUploads.videoId, loomImportJobItems.videoId), + ) + .where(eq(loomImportJobItems.jobId, jobId)) + .orderBy(asc(loomImportJobItems.rowNumber)), + ]); + + const all: LoomImportItemView[] = rows.map((row) => { + const state = deriveLoomImportItemState( + { + status: row.status, + videoId: row.videoId, + error: row.error, + videoExists: Boolean(row.videoExists), + uploadPhase: row.uploadPhase ?? null, + uploadProgress: row.uploadProgress ?? null, + uploadMessage: row.uploadMessage ?? null, + uploadError: row.uploadError ?? null, + }, + job.status, + ); + return { + id: row.id, + row: row.rowNumber, + url: row.loomUrl, + loomId: row.loomVideoId, + title: row.title, + email: row.ownerEmail, + space: row.spaceName, + ...state, + videoId: row.videoId, + recordedAt: row.loomCreatedAt?.toISOString() ?? null, + duration: row.durationSeconds, + thumb: row.thumbnailUrl, + v: Math.max( + time(row.updatedAt), + time(row.videoUpdatedAt), + time(row.uploadUpdatedAt), + ), + }; + }); + + let cursor = 0; + let totalDuration = 0; + let importedDuration = 0; + const owners = new Set(); + for (const item of all) { + cursor = Math.max(cursor, item.v); + if (item.email) owners.add(item.email); + if ( + item.duration && + item.status !== "failed" && + item.status !== "cancelled" + ) { + totalDuration += item.duration; + if (item.status === "imported") importedDuration += item.duration; + } + } + + const isPro = userIsPro(viewer ?? null); + const createdByMe = job.createdById === userId; + const full = since === undefined || !Number.isFinite(since); + + return { + job: { + id: job.id, + fileName: job.fileName, + status: job.status, + totalCount: job.totalCount, + createdAt: job.createdAt.toISOString(), + startedAt: job.startedAt?.toISOString() ?? null, + completedAt: job.completedAt?.toISOString() ?? null, + createdByMe, + canStart: createdByMe && isPro && job.status === "awaiting_upgrade", + isPro, + isAdmin, + }, + counts: countLoomImportItems(all), + totalDuration, + importedDuration, + owners: Math.max(owners.size, 1), + items: full + ? all + : all.filter((item) => item.v > (since as number) - CURSOR_OVERLAP_MS), + cursor, + full, + }; +} diff --git a/apps/web/lib/loom-import/status.ts b/apps/web/lib/loom-import/status.ts new file mode 100644 index 00000000000..6d26126bf52 --- /dev/null +++ b/apps/web/lib/loom-import/status.ts @@ -0,0 +1,219 @@ +import type { + LoomImportItemStatus, + LoomImportJobStatus, +} from "@cap/database/schema"; + +export type { LoomImportItemStatus, LoomImportJobStatus }; + +export type LoomImportDisplayStatus = + | "checking" + | "ready" + | "queued" + | "importing" + | "imported" + | "failed" + | "skipped" + | "cancelled"; + +export type LoomImportStage = + | "starting" + | "waiting" + | "downloading" + | "processing" + | "finishing"; + +export type LoomImportItemSource = { + status: LoomImportItemStatus; + videoId: string | null; + error: string | null; + videoExists: boolean; + uploadPhase: string | null; + uploadProgress: number | null; + uploadMessage: string | null; + uploadError: string | null; +}; + +export type LoomImportItemState = { + status: LoomImportDisplayStatus; + stage?: LoomImportStage; + progress?: number; + error?: string; +}; + +export type LoomImportCounts = Record & { + total: number; +}; + +export type LoomImportItemView = { + id: string; + row: number; + url: string; + loomId: string | null; + title: string | null; + email: string | null; + space: string | null; + status: LoomImportDisplayStatus; + stage?: LoomImportStage; + progress?: number; + error?: string; + videoId: string | null; + recordedAt: string | null; + duration: number | null; + thumb: string | null; + v: number; +}; + +export type LoomImportJobView = { + id: string; + fileName: string; + status: LoomImportJobStatus; + totalCount: number; + createdAt: string; + startedAt: string | null; + completedAt: string | null; + createdByMe: boolean; + canStart: boolean; + isPro: boolean; + isAdmin: boolean; +}; + +export type LoomImportSnapshot = { + job: LoomImportJobView; + counts: LoomImportCounts; + totalDuration: number; + importedDuration: number; + owners: number; + items: LoomImportItemView[]; + cursor: number; + full: boolean; +}; + +export const LOOM_IMPORT_TERMINAL_STATUSES: ReadonlySet = + new Set(["imported", "failed", "skipped", "cancelled"]); + +const DELETED_VIDEO_ERROR = "The imported Cap was deleted."; + +function importStage( + phase: string | null, + progress: number, + message: string | null, +): LoomImportStage { + if (phase === "uploading") return "starting"; + if (phase === "generating_thumbnail" || progress >= 80) return "finishing"; + if (message?.startsWith("Queued")) return "waiting"; + if (progress < 10) return "downloading"; + return "processing"; +} + +export function deriveLoomImportItemState( + source: LoomImportItemSource, + jobStatus: LoomImportJobStatus, +): LoomImportItemState { + switch (source.status) { + case "pending": + return { status: "checking" }; + case "ready": + return { + status: + jobStatus === "awaiting_upgrade" || jobStatus === "checking" + ? "ready" + : "queued", + }; + case "skipped": + return { status: "skipped", error: source.error ?? undefined }; + case "cancelled": + return { status: "cancelled" }; + default: + break; + } + + if (!source.videoId) { + if (source.status === "importing") + return { status: "importing", stage: "starting", progress: 0 }; + if (source.status === "complete") return { status: "imported" }; + return { status: "failed", error: source.error ?? undefined }; + } + + if (!source.videoExists) { + return { status: "failed", error: source.error ?? DELETED_VIDEO_ERROR }; + } + + if (source.uploadPhase === null) { + if (source.status === "failed" && source.error) { + return { status: "failed", error: source.error }; + } + return { status: "imported" }; + } + + if (source.uploadPhase === "error") { + return { + status: "failed", + error: + source.uploadError ?? source.error ?? "Loom import failed. Try again.", + }; + } + + const progress = Math.max(0, Math.min(100, source.uploadProgress ?? 0)); + return { + status: "importing", + stage: importStage(source.uploadPhase, progress, source.uploadMessage), + progress, + }; +} + +export function emptyLoomImportCounts(): LoomImportCounts { + return { + checking: 0, + ready: 0, + queued: 0, + importing: 0, + imported: 0, + failed: 0, + skipped: 0, + cancelled: 0, + total: 0, + }; +} + +export function countLoomImportItems( + items: Iterable<{ status: LoomImportDisplayStatus }>, +): LoomImportCounts { + const counts = emptyLoomImportCounts(); + for (const item of items) { + counts[item.status]++; + counts.total++; + } + return counts; +} + +export function loomImportProgress( + counts: LoomImportCounts, + importingProgress = 0, +) { + const settled = + counts.imported + counts.failed + counts.skipped + counts.cancelled; + const total = counts.total; + if (total === 0) return 0; + return Math.min(1, (settled + importingProgress / 100) / total); +} + +export function isLoomImportJobActive(status: LoomImportJobStatus) { + return status === "checking" || status === "importing"; +} + +export function loomImportStageLabel(stage: LoomImportStage | undefined) { + switch (stage) { + case "starting": + return "Starting"; + case "waiting": + return "Waiting for a slot"; + case "downloading": + return "Downloading from Loom"; + case "processing": + return "Copying to Cap"; + case "finishing": + return "Making previews"; + default: + return "Importing"; + } +} diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts index e821e92d885..cc94f36e128 100644 --- a/apps/web/workflows/import-loom-video.ts +++ b/apps/web/workflows/import-loom-video.ts @@ -7,6 +7,7 @@ import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect } from "effect"; import { FatalError, sleep } from "workflow"; +import { dispatchLoomImportForVideo } from "@/lib/loom-import/dispatch"; import { createMediaServerCapacityError, isMediaServerCapacityError, @@ -236,6 +237,7 @@ export async function importLoomVideoWorkflow( } await saveMetadataAndComplete(payload.videoId, metadata); await completeAgentImport(payload.agentOperationId, payload.videoId); + await continueLoomImportJob(payload.videoId); return { success: true, @@ -246,10 +248,24 @@ export async function importLoomVideoWorkflow( const errorMessage = error instanceof Error ? error.message : String(error); await setProcessingError(payload.videoId, errorMessage); await failAgentImport(payload.agentOperationId, errorMessage); + await continueLoomImportJob(payload.videoId); throw new FatalError(errorMessage); } } +async function continueLoomImportJob(videoId: string): Promise { + "use step"; + + try { + await dispatchLoomImportForVideo(videoId); + } catch (error) { + console.error("[import-loom-video] Could not continue Loom import job", { + videoId, + error, + }); + } +} + function getInputExtension(url: string): string | undefined { const pathname = new URL(url).pathname.toLowerCase(); diff --git a/apps/web/workflows/loom-import-job.ts b/apps/web/workflows/loom-import-job.ts new file mode 100644 index 00000000000..aa39c54c8b4 --- /dev/null +++ b/apps/web/workflows/loom-import-job.ts @@ -0,0 +1,41 @@ +import { dispatchLoomImportJob } from "@/lib/loom-import/dispatch"; +import { + prepareLoomImportJob, + resolveLoomImportJob, +} from "@/lib/loom-import/jobs"; + +interface LoomImportJobPayload { + jobId: string; +} + +async function resolveLoomImportJobStep(jobId: string) { + "use step"; + + await resolveLoomImportJob(jobId); +} + +async function prepareLoomImportJobStep(jobId: string) { + "use step"; + + return prepareLoomImportJob(jobId); +} + +async function dispatchLoomImportJobStep(jobId: string) { + "use step"; + + return dispatchLoomImportJob(jobId); +} + +export async function loomImportJobWorkflow({ jobId }: LoomImportJobPayload) { + "use workflow"; + + await resolveLoomImportJobStep(jobId); + const status = await prepareLoomImportJobStep(jobId); + if (status !== "importing") return { jobId, status }; + const dispatched = await dispatchLoomImportJobStep(jobId); + return { + jobId, + status: dispatched.completed ? "completed" : "importing", + started: dispatched.started, + }; +} diff --git a/packages/env/server.ts b/packages/env/server.ts index 78d649a442f..e488c019fce 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -188,6 +188,12 @@ function createServerEnv() { .describe( "Base URL for media server webhooks (use host.docker.internal for Docker setups)", ), + LOOM_IMPORT_CONCURRENCY: z + .string() + .optional() + .describe( + "How many videos each Loom CSV import sends to the media server at once (default 4)", + ), /// Ignore NODE_ENV: z.string(), From bb06457554b54b9402f7522d13ffd30247000f62 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 04:49:16 +0000 Subject: [PATCH 03/39] fix: only transcribe imported Loom videos once someone watches them The media-server webhook queued transcription for every finished web MP4, and stalled-pipeline recovery picked up any recent video without a transcript, so bulk Loom imports paid for transcription and AI on videos nobody opened. Imported Loom videos now wait for the share page or embed to start them on first view, and the webhook refills the import window instead. Co-authored-by: Richie McIlroy --- .../unit/media-server-progress.test.ts | 50 +++++++++++++++++++ .../unit/video-pipeline-recovery.test.ts | 31 ++++++++++++ .../webhooks/media-server/progress/route.ts | 26 +++++++++- apps/web/lib/video-pipeline-recovery.ts | 10 +++- 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/media-server-progress.test.ts b/apps/web/__tests__/unit/media-server-progress.test.ts index f4e817178a3..50d2ce7fb0d 100644 --- a/apps/web/__tests__/unit/media-server-progress.test.ts +++ b/apps/web/__tests__/unit/media-server-progress.test.ts @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ transcribe: vi.fn(), invalidateQuota: vi.fn(), audio: vi.fn(), + isLoomImport: vi.fn(), + continueLoomImport: vi.fn(), tables: { videos: { id: "videos.id", @@ -68,6 +70,10 @@ vi.mock("@/lib/google-drive-storage-quota", () => ({ vi.mock("@/lib/google-drive-storage-quota-cache", () => ({ invalidateGoogleDriveStorageQuotaCache: mocks.invalidateQuota, })); +vi.mock("@/lib/loom-import/dispatch", () => ({ + isLoomImportedVideo: mocks.isLoomImport, + dispatchLoomImportForVideo: mocks.continueLoomImport, +})); vi.mock("@/lib/queue-video-transcription", () => ({ queueVideoTranscription: mocks.transcribe, shouldQueueTranscriptionAfterMediaComplete: () => true, @@ -253,6 +259,17 @@ function databaseFixture(initial: DesktopRecordingJob | null = fixture().job) { }; } +function webPayload(overrides: Record = {}) { + return { + jobId: "media-job-1", + videoId, + phase: "complete", + progress: 100, + metadata: { ...metadata, bitrate: 1_000_000 }, + ...overrides, + }; +} + function request( body: unknown = fixture().payload, secret: string | null = "media-secret", @@ -283,6 +300,8 @@ describe("media-server recording progress webhook", () => { ); mocks.queue.mockReset().mockResolvedValue("queued"); mocks.transcribe.mockReset().mockResolvedValue({ success: true }); + mocks.isLoomImport.mockReset().mockResolvedValue(false); + mocks.continueLoomImport.mockReset().mockResolvedValue(null); mocks.invalidateQuota.mockResolvedValue(undefined); mocks.retry.mockResolvedValue(true); mocks.blocked.mockResolvedValue(true); @@ -331,6 +350,37 @@ describe("media-server recording progress webhook", () => { }, ); + it("leaves AI for imported Loom videos until they are viewed and keeps the import moving", async () => { + const database = databaseFixture(null); + database.video.source = { type: "webMP4" }; + mocks.isLoomImport.mockResolvedValue(true); + expect((await request(webPayload())).status).toBe(200); + expect(mocks.isLoomImport).toHaveBeenCalledExactlyOnceWith(videoId); + expect(mocks.transcribe).not.toHaveBeenCalled(); + expect(mocks.continueLoomImport).toHaveBeenCalledExactlyOnceWith(videoId); + expect(database.mutations).toContainEqual({ + operation: "delete", + table: mocks.tables.uploads, + }); + }); + + it("still transcribes ordinary web uploads as soon as processing finishes", async () => { + const database = databaseFixture(null); + database.video.source = { type: "webMP4" }; + expect((await request(webPayload())).status).toBe(200); + expect(mocks.transcribe).toHaveBeenCalledExactlyOnceWith(videoId); + expect(mocks.continueLoomImport).not.toHaveBeenCalled(); + }); + + it("keeps a Loom import moving when a video fails for good", async () => { + const database = databaseFixture(null); + database.video.source = { type: "webMP4" }; + const failed = webPayload({ phase: "error", error: "Source unavailable" }); + expect((await request(failed)).status).toBe(200); + expect(mocks.continueLoomImport).toHaveBeenCalledExactlyOnceWith(videoId); + expect(mocks.transcribe).not.toHaveBeenCalled(); + }); + it("refuses callbacks when the webhook secret is not configured", async () => { databaseFixture(); mocks.secret = undefined; diff --git a/apps/web/__tests__/unit/video-pipeline-recovery.test.ts b/apps/web/__tests__/unit/video-pipeline-recovery.test.ts index dd1de83dfdd..d362bada4e5 100644 --- a/apps/web/__tests__/unit/video-pipeline-recovery.test.ts +++ b/apps/web/__tests__/unit/video-pipeline-recovery.test.ts @@ -188,6 +188,37 @@ describe("recoverStalledVideoPipeline", () => { expect(result.ai.statuses).toEqual({ started: 1 }); }); + it("leaves never-watched Loom imports for their first view instead of transcribing them", async () => { + const chains: ReturnType[] = []; + mockDb.mockImplementation(() => { + const chain = makeSelectChain([]); + chains.push(chain); + return chain; + }); + + const { recoverStalledVideoPipeline } = await import( + "@/lib/video-pipeline-recovery" + ); + await recoverStalledVideoPipeline({ now, concurrency: 1 }); + + const transcriptionQuery = chains[1]; + expect(transcriptionQuery?.leftJoin).toHaveBeenCalledWith( + expect.objectContaining({ id: "importedVideos.id" }), + expect.anything(), + ); + const conditions = JSON.stringify(transcriptionQuery?.where.mock.calls[0]); + expect(conditions).toContain( + JSON.stringify(["videos.transcriptionStatus", "importedVideos.id"]), + ); + expect(conditions).toContain( + JSON.stringify({ + left: "videos.transcriptionStatus", + right: "PROCESSING", + }), + ); + expect(mockTranscribeVideo).not.toHaveBeenCalled(); + }); + it("does not start a duplicate media workflow after a lost claim", async () => { const mediaCandidate = { videoId: "video-media", 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 9e4a7bf825f..4426c2a823c 100644 --- a/apps/web/app/api/webhooks/media-server/progress/route.ts +++ b/apps/web/app/api/webhooks/media-server/progress/route.ts @@ -15,6 +15,10 @@ import { } from "@/lib/desktop-recording-verification"; import { queueDesktopSegmentsFinalization } from "@/lib/desktop-segments-finalization"; import { invalidateGoogleDriveStorageQuotaCache } from "@/lib/google-drive-storage-quota"; +import { + dispatchLoomImportForVideo, + isLoomImportedVideo, +} from "@/lib/loom-import/dispatch"; import { queueVideoTranscription, shouldQueueTranscriptionAfterMediaComplete, @@ -62,6 +66,17 @@ function getValidDuration(duration: number) { return Number.isFinite(duration) && duration > 0 ? duration : undefined; } +async function continueLoomImport(videoId: Video.VideoId) { + try { + await dispatchLoomImportForVideo(videoId); + } catch (error) { + console.warn("[media-server-webhook] Could not continue Loom import", { + videoId, + error, + }); + } +} + function mapPhaseToDbPhase( phase: ProgressWebhookPayload["phase"], ): "uploading" | "processing" | "generating_thumbnail" | "complete" | "error" { @@ -262,7 +277,15 @@ export async function POST(request: NextRequest) { currentVideo?.storageIntegrationId, ); - if ( + const videoId = payload.videoId as Video.VideoId; + const isLoomImport = + !isEditUpload && + currentVideo?.source.type === "webMP4" && + (await isLoomImportedVideo(videoId)); + + if (isLoomImport) { + await continueLoomImport(videoId); + } else if ( shouldQueueTranscriptionAfterMediaComplete( currentVideo?.source.type, Boolean(isEditUpload), @@ -309,6 +332,7 @@ export async function POST(request: NextRequest) { updatedAt: new Date(), }) .where(eq(videoUploads.videoId, payload.videoId as Video.VideoId)); + await continueLoomImport(payload.videoId as Video.VideoId); } } else { await db() diff --git a/apps/web/lib/video-pipeline-recovery.ts b/apps/web/lib/video-pipeline-recovery.ts index 060df34392d..85dc8cf018f 100644 --- a/apps/web/lib/video-pipeline-recovery.ts +++ b/apps/web/lib/video-pipeline-recovery.ts @@ -342,6 +342,14 @@ export async function recoverStalledVideoPipeline({ .from(videos) .innerJoin(users, eq(videos.ownerId, users.id)) .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .leftJoin( + importedVideos, + and( + eq(importedVideos.id, videos.id), + eq(importedVideos.orgId, videos.orgId), + eq(importedVideos.source, "loom"), + ), + ) .where( and( isNull(videoUploads.videoId), @@ -349,7 +357,7 @@ export async function recoverStalledVideoPipeline({ gte(videos.createdAt, recentBefore), lte(videos.updatedAt, staleBefore), or( - isNull(videos.transcriptionStatus), + and(isNull(videos.transcriptionStatus), isNull(importedVideos.id)), eq(videos.transcriptionStatus, "PROCESSING"), ), ), From 9aca43ccb99a98c45f6a7b675afc9c66cc883f23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 04:49:16 +0000 Subject: [PATCH 04/39] feat: guided Loom bulk import with live progress and upgrade flow Hand-drawn walkthrough of the import, a drop zone that also takes pasted links, automatic column detection with a review step, a live job page with a virtualized list of every video, and an upgrade panel that lets free users check their whole library before upgrading. Checkout returns to the job and starts it. Co-authored-by: Richie McIlroy --- .../__tests__/unit/loom-import-status.test.ts | 291 ++++++ .../web/__tests__/unit/loom-import-ui.test.ts | 4 +- .../dashboard/import/loom/ImportLoomPage.tsx | 960 +----------------- .../import/loom/[jobId]/LoomImportJobView.tsx | 635 ++++++++++++ .../import/loom/[jobId]/import-list.tsx | 297 ++++++ .../dashboard/import/loom/[jobId]/page.tsx | 35 + .../import/loom/[jobId]/upgrade-panel.tsx | 287 ++++++ .../loom/[jobId]/use-loom-import-job.ts | 187 ++++ .../import/loom/_components/bulk-import.tsx | 668 ++++++++++++ .../import/loom/_components/doodles.tsx | 495 +++++++++ .../loom/_components/how-import-works.tsx | 578 +++++++++++ .../import/loom/_components/loom-import.css | 260 +++++ .../loom/_components/recent-imports.tsx | 90 ++ .../app/(org)/dashboard/import/loom/page.tsx | 12 +- .../api/settings/billing/subscribe/route.ts | 15 +- 15 files changed, 3885 insertions(+), 929 deletions(-) create mode 100644 apps/web/__tests__/unit/loom-import-status.test.ts create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/LoomImportJobView.tsx create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx create mode 100644 apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/doodles.tsx create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/how-import-works.tsx create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/loom-import.css create mode 100644 apps/web/app/(org)/dashboard/import/loom/_components/recent-imports.tsx diff --git a/apps/web/__tests__/unit/loom-import-status.test.ts b/apps/web/__tests__/unit/loom-import-status.test.ts new file mode 100644 index 00000000000..d5f438ece88 --- /dev/null +++ b/apps/web/__tests__/unit/loom-import-status.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from "vitest"; +import { mergeLoomImportItems } from "@/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job"; +import { + buildLoomVideosQuery, + lookupLoomVideos, + toLoomVideoLookup, +} from "@/lib/loom-import/loom-api"; +import { buildLoomImportReport } from "@/lib/loom-import/report"; +import { + countLoomImportItems, + deriveLoomImportItemState, + type LoomImportItemSource, + type LoomImportItemView, + type LoomImportSnapshot, + loomImportProgress, +} from "@/lib/loom-import/status"; + +const source = ( + overrides: Partial, +): LoomImportItemSource => ({ + status: "importing", + videoId: "video-1", + error: null, + videoExists: true, + uploadPhase: "processing", + uploadProgress: 30, + uploadMessage: "Processing video...", + uploadError: null, + ...overrides, +}); + +describe("deriveLoomImportItemState", () => { + it("shows checked rows as ready before an upgrade and queued once importing", () => { + expect( + deriveLoomImportItemState( + source({ status: "ready" }), + "awaiting_upgrade", + ), + ).toEqual({ + status: "ready", + }); + expect( + deriveLoomImportItemState(source({ status: "ready" }), "importing"), + ).toEqual({ + status: "queued", + }); + }); + + it("reads live media progress and friendly stages", () => { + expect(deriveLoomImportItemState(source({}), "importing")).toEqual({ + status: "importing", + stage: "processing", + progress: 30, + }); + expect( + deriveLoomImportItemState( + source({ uploadPhase: "uploading", uploadProgress: 0 }), + "importing", + ).stage, + ).toBe("starting"); + expect( + deriveLoomImportItemState( + source({ + uploadMessage: "Queued for Loom import processing...", + uploadProgress: 0, + }), + "importing", + ).stage, + ).toBe("waiting"); + expect( + deriveLoomImportItemState( + source({ uploadPhase: "generating_thumbnail", uploadProgress: 90 }), + "importing", + ).stage, + ).toBe("finishing"); + }); + + it("treats a finished upload as imported even if the job row lags behind", () => { + expect( + deriveLoomImportItemState(source({ uploadPhase: null }), "importing"), + ).toEqual({ + status: "imported", + }); + }); + + it("surfaces media errors, deleted Caps and retries started elsewhere", () => { + expect( + deriveLoomImportItemState( + source({ uploadPhase: "error", uploadError: "Source unavailable" }), + "importing", + ), + ).toEqual({ status: "failed", error: "Source unavailable" }); + expect( + deriveLoomImportItemState(source({ videoExists: false }), "completed"), + ).toEqual({ + status: "failed", + error: "The imported Cap was deleted.", + }); + expect( + deriveLoomImportItemState( + source({ status: "failed", error: "Old failure" }), + "completed", + ).status, + ).toBe("importing"); + }); +}); + +describe("progress", () => { + it("counts partial progress of videos that are mid-copy", () => { + const counts = countLoomImportItems([ + { status: "imported" }, + { status: "failed" }, + { status: "importing" }, + { status: "queued" }, + ]); + expect(counts).toMatchObject({ + imported: 1, + failed: 1, + importing: 1, + queued: 1, + total: 4, + }); + expect(loomImportProgress(counts, 50)).toBeCloseTo(0.625); + }); +}); + +describe("Loom lookups", () => { + it("maps Loom's video types to import outcomes", () => { + expect( + toLoomVideoLookup({ + __typename: "RegularUserVideo", + name: " Weekly update ", + createdAt: "2022-08-05T21:16:35.487Z", + thumbnails: { default: "https://cdn.loom.com/thumb.jpg" }, + video_properties: { duration: 154, width: 1152, height: 720 }, + }), + ).toEqual({ + status: "ok", + title: "Weekly update", + createdAt: "2022-08-05T21:16:35.487Z", + durationSeconds: 154, + width: 1152, + height: 720, + thumbnailUrl: "https://cdn.loom.com/thumb.jpg", + }); + expect(toLoomVideoLookup({ __typename: "PrivateVideo" })).toEqual({ + status: "private", + }); + expect( + toLoomVideoLookup({ __typename: "VideoPasswordMissingOrIncorrect" }), + ).toEqual({ + status: "password", + }); + expect(toLoomVideoLookup(null)).toEqual({ status: "not_found" }); + for (const unsafe of [ + "javascript:alert(1)", + "http://cdn.loom.com/thumb.jpg", + ]) { + expect( + toLoomVideoLookup({ + __typename: "RegularUserVideo", + thumbnails: { default: unsafe }, + }), + ).toMatchObject({ status: "ok", thumbnailUrl: null }); + } + }); + + it("asks for many videos per request with aliases", () => { + const query = buildLoomVideosQuery(["a", "b"]); + expect(query.query).toContain("v0: getVideo(id: $id0"); + expect(query.query).toContain("v1: getVideo(id: $id1"); + expect(query.variables).toEqual({ id0: "a", id1: "b" }); + }); + + it("batches lookups, retries a failed request and reports batches as they land", async () => { + const ids = Array.from({ length: 60 }, (_, index) => `id${index}`); + let calls = 0; + const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => { + calls++; + if (calls === 1) return new Response("busy", { status: 503 }); + const { variables } = JSON.parse(String(init?.body)) as { + variables: Record; + }; + const data = Object.fromEntries( + Object.keys(variables).map((key) => [ + key.replace("id", "v"), + { __typename: "RegularUserVideo", name: variables[key] }, + ]), + ); + return Response.json({ data }); + }) as unknown as typeof fetch; + const batches: number[] = []; + const results = await lookupLoomVideos([...ids, "id0"], { + fetchImpl, + concurrency: 2, + onBatch: (batch) => { + batches.push(batch.size); + }, + }); + expect(results.size).toBe(60); + expect(batches.sort()).toEqual([10, 25, 25]); + expect(fetchImpl).toHaveBeenCalledTimes(4); + expect(results.get("id42")).toMatchObject({ status: "ok", title: "id42" }); + }); + + it("gives up cleanly when Loom keeps failing", async () => { + const fetchImpl = vi.fn( + async () => new Response("no", { status: 500 }), + ) as unknown as typeof fetch; + const results = await lookupLoomVideos(["a"], { fetchImpl, attempts: 2 }); + expect(results.get("a")).toEqual({ status: "error" }); + }); +}); + +const view = (overrides: Partial): LoomImportItemView => ({ + id: "item-1", + row: 2, + url: "https://www.loom.com/share/abc", + loomId: "abc", + title: "Kickoff", + email: "ana@acme.com", + space: null, + status: "imported", + videoId: "video-1", + recordedAt: "2022-08-05T21:16:35.487Z", + duration: 154, + thumb: null, + v: 1, + ...overrides, +}); + +describe("results report", () => { + it("maps every Loom link to its new Cap link and neutralizes spreadsheet formulas", () => { + const csv = buildLoomImportReport( + [ + view({}), + view({ + id: "item-2", + row: 3, + title: '=HYPERLINK("x"), "quoted"', + status: "failed", + videoId: null, + error: "This Loom is private.", + }), + ], + "https://cap.so", + ); + expect(csv.split("\n")).toEqual([ + "row,loom_url,title,recorded_at,owner_email,space,status,cap_url,note", + "2,https://www.loom.com/share/abc,Kickoff,2022-08-05T21:16:35.487Z,ana@acme.com,,imported,https://cap.so/s/video-1,", + `3,https://www.loom.com/share/abc,"'=HYPERLINK(""x""), ""quoted""",2022-08-05T21:16:35.487Z,ana@acme.com,,failed,,This Loom is private.`, + "", + ]); + }); +}); + +describe("mergeLoomImportItems", () => { + const snapshot = (items: LoomImportItemView[], full: boolean) => + ({ items, full, cursor: 0 }) as unknown as LoomImportSnapshot; + + it("replaces only rows that changed and keeps CSV order", () => { + const map = new Map(); + const first = mergeLoomImportItems( + map, + [], + snapshot([view({ id: "a", row: 2 }), view({ id: "b", row: 3 })], true), + ); + expect(first).toEqual({ order: ["a", "b"], changed: true }); + const original = map.get("a"); + + const unchanged = mergeLoomImportItems( + map, + first.order, + snapshot([view({ id: "a", row: 2 })], false), + ); + expect(unchanged.changed).toBe(false); + expect(map.get("a")).toBe(original); + + const progressed = mergeLoomImportItems( + map, + first.order, + snapshot( + [view({ id: "b", row: 3, status: "importing", progress: 40, v: 2 })], + false, + ), + ); + expect(progressed).toEqual({ order: ["a", "b"], changed: true }); + expect(map.get("a")).toBe(original); + expect(map.get("b")?.progress).toBe(40); + }); +}); diff --git a/apps/web/__tests__/unit/loom-import-ui.test.ts b/apps/web/__tests__/unit/loom-import-ui.test.ts index d92f8e90913..22cc595c77c 100644 --- a/apps/web/__tests__/unit/loom-import-ui.test.ts +++ b/apps/web/__tests__/unit/loom-import-ui.test.ts @@ -99,7 +99,9 @@ vi.mock("next/link", () => ({ vi.mock("@/actions/loom", () => ({ getLoomImportFolders: mocks.folders, importFromLoom: mocks.import, - importFromLoomCsv: vi.fn(), +})); +vi.mock("@/actions/loom-import", () => ({ + createLoomImportJobAction: vi.fn(), })); vi.mock("@cap/utils", async () => await import("@cap/utils/helpers")); vi.mock("@cap/ui", async () => ({ diff --git a/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx b/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx index ebca8079829..a3fbf7b366b 100644 --- a/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx +++ b/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx @@ -2,34 +2,18 @@ import { Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, Input, - Select, SelectContent, SelectItem, SelectRoot, SelectTrigger, SelectValue, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, } from "@cap/ui"; import { Folder } from "@cap/web-domain"; import { faArrowLeft, - faCircleCheck, - faDownload, faFileCsv, faLink, - faTriangleExclamation, - faUpload, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQuery } from "@tanstack/react-query"; @@ -37,24 +21,12 @@ import clsx from "clsx"; import { motion } from "framer-motion"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { - type ChangeEvent, - type DragEvent, - useId, - useMemo, - useRef, - useState, -} from "react"; +import { useId, useMemo, useState } from "react"; import { toast } from "sonner"; -import { - getLoomImportFolders, - importFromLoom, - importFromLoomCsv, - type LoomCsvImportResult, - type LoomCsvImportRowResult, -} from "@/actions/loom"; +import { getLoomImportFolders, importFromLoom } from "@/actions/loom"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import { UpgradeModal } from "@/components/UpgradeModal"; +import type { LoomImportJobSummary } from "@/lib/loom-import/jobs"; import { type LoomImportDestination, loomImportDestinationHref, @@ -65,194 +37,20 @@ import { canManageOrganizationSettings, getEffectiveOrganizationRole, } from "@/lib/permissions/roles"; +import { BulkImport } from "./_components/bulk-import"; +import { LoomMark } from "./_components/doodles"; +import { RecentImports } from "./_components/recent-imports"; type Mode = "single" | "csv"; -type CsvData = { - fileName: string; - headers: string[]; - rows: string[][]; -}; - -type Mapping = { - loomUrl?: string; - userEmail?: string; - spaceName?: string; -}; - -type MappedRow = { - rowNumber: number; - loomUrl: string; - userEmail: string; - spaceName: string; -}; - -const LOOM_CSV_TEMPLATE = - "loom_video_url,user_email,space_name\nhttps://www.loom.com/share/0123456789abcdef,user@example.com,Sales\n"; - const ROOT_FOLDER_VALUE = "__cap_root_folder__"; -const OPTIONAL_COLUMN_VALUE = "__cap_skip_column__"; -const MAX_SPACE_NAME_LENGTH = 255; -const MAX_LOOM_CSV_IMPORT_ROWS = 500; -const LOOM_CSV_BATCH_SIZE = 10; -const LOOM_CSV_BATCH_DELAY_MS = 1500; -const LOOM_CSV_LIMIT_MESSAGE = - "CSV imports are limited to 500 videos at a time. Contact support to raise this limit."; -const LOOM_CSV_PERMISSION_MESSAGE = - "Only organization admins and owners can import Loom videos from a CSV."; - -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function chunkRows(rows: T[], size: number) { - const chunks: T[][] = []; - for (let index = 0; index < rows.length; index += size) { - chunks.push(rows.slice(index, index + size)); - } - return chunks; -} - -function buildCsvImportResult( - results: LoomCsvImportRowResult[], - error?: string, -): LoomCsvImportResult { - const importedCount = results.filter((row) => row.success).length; - const failedCount = results.length - importedCount; - - return { - success: importedCount > 0, - importedCount, - failedCount, - results, - error: - error ?? - (importedCount > 0 ? undefined : "No Loom videos were imported."), - }; -} - -function parseCsvRecords(text: string) { - const records: string[][] = []; - let field = ""; - let row: string[] = []; - let inQuotes = false; - const input = text.replace(/^\uFEFF/, ""); - - for (let index = 0; index < input.length; index += 1) { - const char = input.charAt(index); - const next = input.charAt(index + 1); - - if (char === '"') { - if (inQuotes && next === '"') { - field += '"'; - index += 1; - } else { - inQuotes = !inQuotes; - } - continue; - } - - if (char === "," && !inQuotes) { - row.push(field.trim()); - field = ""; - continue; - } - - if ((char === "\n" || char === "\r") && !inQuotes) { - if (char === "\r" && next === "\n") index += 1; - row.push(field.trim()); - if (row.some((cell) => cell.length > 0)) records.push(row); - row = []; - field = ""; - continue; - } - - field += char; - } - - if (inQuotes) throw new Error("CSV has an unclosed quoted field."); - - if (field.length > 0 || row.length > 0) { - row.push(field.trim()); - if (row.some((cell) => cell.length > 0)) records.push(row); - } - - return records; -} - -function parseCsv(text: string, fileName: string): CsvData { - const records = parseCsvRecords(text); - const headers = records[0]?.map((header) => header.trim()) ?? []; - const rows = records - .slice(1) - .filter((row) => row.some((cell) => cell.trim().length > 0)); - - if (headers.length === 0) { - throw new Error("No CSV headers found."); - } - - return { fileName, headers, rows }; -} - -function normalizeHeader(value: string) { - return value.toLowerCase().replace(/[^a-z0-9]/g, ""); -} - -function guessColumn(headers: string[], candidates: string[]) { - const normalizedHeaders = headers.map(normalizeHeader); - const directMatch = normalizedHeaders.findIndex((header) => - candidates.includes(header), - ); - if (directMatch !== -1) return String(directMatch); - - const partialMatch = normalizedHeaders.findIndex((header) => - candidates.some((candidate) => header.includes(candidate)), - ); - return partialMatch === -1 ? undefined : String(partialMatch); -} - -function isLoomUrl(value: string) { - try { - return new URL(value).hostname.includes("loom.com"); - } catch { - return false; - } -} - -function isEmail(value: string) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); -} - -function isValidSpaceName(value: string) { - return value.length <= MAX_SPACE_NAME_LENGTH; -} - -function pluralize(count: number, singular: string, plural: string) { - return count === 1 ? singular : plural; -} - -const LoomMark = ({ size = 18 }: { size?: number }) => ( - - - -); - export const ImportLoomPage = ({ initialDestination = {}, + recentJobs = [], }: { initialDestination?: LoomImportDestination; + recentJobs?: LoomImportJobSummary[]; }) => { const { user, activeOrganization, spacesData } = useDashboardContext(); const router = useRouter(); @@ -269,11 +67,10 @@ export const ImportLoomPage = ({ ownerId: activeOrganization?.organization.ownerId, memberRole: currentMember?.role, }); - const canUseCsvImport = canManageOrganizationSettings(currentRole); + const isAdmin = canManageOrganizationSettings(currentRole); const [mode, setMode] = useState(prefilledMode); - const activeMode = canUseCsvImport ? mode : "single"; - const [upgradeModalOpen, setUpgradeModalOpen] = useState(!user?.isPro); + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [selectedFolderId, setSelectedFolderId] = useState( initialDestination.folderId ?? ROOT_FOLDER_VALUE, @@ -285,7 +82,7 @@ export const ImportLoomPage = ({ queryKey: ["loom-import-folders", user.id, orgId, spaceId], queryFn: () => orgId ? getLoomImportFolders({ orgId, spaceId }) : Promise.resolve([]), - enabled: Boolean(orgId) && activeMode === "single", + enabled: Boolean(orgId) && mode === "single", }); const folderRows = useMemo( () => buildMoveFolderDestinationRows(foldersQuery.data ?? []), @@ -317,76 +114,6 @@ export const ImportLoomPage = ({ const [loomUrl, setLoomUrl] = useState(prefilledLoomUrl); const [isImporting, setIsImporting] = useState(false); - const inputRef = useRef(null); - const [csvData, setCsvData] = useState(null); - const [mapping, setMapping] = useState({}); - const [isDragOver, setIsDragOver] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); - const [isCsvImporting, setIsCsvImporting] = useState(false); - const [csvImportProgress, setCsvImportProgress] = useState(0); - const [result, setResult] = useState(null); - - const selectedColumnValues = [ - mapping.loomUrl, - mapping.userEmail, - mapping.spaceName, - ].filter((value) => value !== undefined); - const selectedColumnsConflict = - new Set(selectedColumnValues).size !== selectedColumnValues.length; - - const mappedRows = useMemo(() => { - if ( - !csvData || - mapping.loomUrl === undefined || - mapping.userEmail === undefined - ) { - return []; - } - - const loomIndex = Number(mapping.loomUrl); - const emailIndex = Number(mapping.userEmail); - const spaceIndex = - mapping.spaceName === undefined ? undefined : Number(mapping.spaceName); - - return csvData.rows - .map((row, index) => ({ - rowNumber: index + 2, - loomUrl: (row[loomIndex] ?? "").trim(), - userEmail: (row[emailIndex] ?? "").trim().toLowerCase(), - spaceName: - spaceIndex === undefined ? "" : (row[spaceIndex] ?? "").trim(), - })) - .filter((row) => row.loomUrl || row.userEmail || row.spaceName); - }, [csvData, mapping.loomUrl, mapping.spaceName, mapping.userEmail]); - - const readyRows = useMemo( - () => - mappedRows.filter( - (row) => - isLoomUrl(row.loomUrl) && - isEmail(row.userEmail) && - isValidSpaceName(row.spaceName), - ), - [mappedRows], - ); - - const invalidRows = mappedRows.length - readyRows.length; - const previewRows = mappedRows.slice(0, 5); - const csvLimitExceeded = readyRows.length > MAX_LOOM_CSV_IMPORT_ROWS; - const canImport = - canUseCsvImport && - !!activeOrganization && - !selectedColumnsConflict && - readyRows.length > 0 && - !csvLimitExceeded && - !isCsvImporting; - - const columnOptions = - csvData?.headers.map((header, index) => ({ - value: String(index), - label: header || `Column ${index + 1}`, - })) ?? []; - const isValidLoomUrl = (() => { try { const parsed = new URL(loomUrl.trim()); @@ -431,153 +158,6 @@ export const ImportLoomPage = ({ } }; - const handleTemplateDownload = () => { - const blob = new Blob([LOOM_CSV_TEMPLATE], { - type: "text/csv;charset=utf-8", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "cap-loom-import-template.csv"; - link.click(); - URL.revokeObjectURL(url); - }; - - const loadCsvFile = async (file: File) => { - if (!user) return; - - if (!canUseCsvImport) { - toast.error(LOOM_CSV_PERMISSION_MESSAGE); - return; - } - - if (!user.isPro) { - setUpgradeModalOpen(true); - return; - } - - if (!file.name.toLowerCase().endsWith(".csv") && file.type !== "text/csv") { - toast.error("Please upload a CSV file."); - return; - } - - try { - const parsed = parseCsv(await file.text(), file.name); - if (parsed.rows.length > MAX_LOOM_CSV_IMPORT_ROWS) { - toast.error(LOOM_CSV_LIMIT_MESSAGE); - } - - const loomUrlGuess = guessColumn(parsed.headers, [ - "loomvideourl", - "loomurl", - "loomlink", - "videourl", - "url", - ]); - const userEmailGuess = guessColumn(parsed.headers, [ - "useremail", - "memberemail", - "owneremail", - "email", - ]); - const spaceNameGuess = guessColumn(parsed.headers, [ - "spacename", - "space", - "workspace", - "workspacename", - ]); - - setCsvData(parsed); - setMapping({ - loomUrl: loomUrlGuess, - userEmail: userEmailGuess, - spaceName: spaceNameGuess, - }); - setResult(null); - setCsvImportProgress(0); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Could not parse CSV.", - ); - } - }; - - const handleFileChange = async (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - await loadCsvFile(file); - if (inputRef.current) inputRef.current.value = ""; - }; - - const handleDrop = async (event: DragEvent) => { - event.preventDefault(); - setIsDragOver(false); - const file = event.dataTransfer.files[0]; - if (!file) return; - await loadCsvFile(file); - }; - - const handleCsvImport = async () => { - if (csvLimitExceeded) { - toast.error(LOOM_CSV_LIMIT_MESSAGE); - return; - } - - if (!activeOrganization || !canImport) return; - - setIsCsvImporting(true); - setResult(null); - setCsvImportProgress(0); - - try { - const batches = chunkRows(readyRows, LOOM_CSV_BATCH_SIZE); - let combinedResults: LoomCsvImportRowResult[] = []; - let blockedError: string | undefined; - - for (const [batchIndex, batch] of batches.entries()) { - const importResult = await importFromLoomCsv({ - orgId: activeOrganization.organization.id, - rows: batch, - }); - - if (importResult.results.length === 0 && importResult.error) { - blockedError = importResult.error; - break; - } - - combinedResults = [...combinedResults, ...importResult.results]; - setCsvImportProgress(combinedResults.length); - setResult(buildCsvImportResult(combinedResults)); - - if (batchIndex < batches.length - 1) { - await delay(LOOM_CSV_BATCH_DELAY_MS); - } - } - - const finalResult = buildCsvImportResult(combinedResults, blockedError); - setResult(finalResult); - - if (finalResult.importedCount > 0) { - toast.success( - `${finalResult.importedCount} ${pluralize( - finalResult.importedCount, - "Loom import", - "Loom imports", - )} started.`, - ); - router.refresh(); - } else { - toast.error(finalResult.error || "No Loom videos were imported."); - } - - setConfirmOpen(false); - } catch { - toast.error("An unexpected error occurred. Please try again."); - } finally { - setIsCsvImporting(false); - } - }; - return (
@@ -597,37 +177,34 @@ export const ImportLoomPage = ({ Import from Loom

- {canUseCsvImport - ? "Bring a single Loom video into Cap, or bulk import recordings for organization members and new users from a CSV." - : "Paste a Loom share link to bring it into Cap."} + Bring one Loom over with a link, or move a whole library from a + CSV. Titles and original recording dates come with them.

- {canUseCsvImport && ( -
- setMode("single")} - /> - setMode("csv")} - /> -
- )} +
+ setMode("single")} + /> + setMode("csv")} + /> +
- {activeMode === "single" ? ( + {mode === "single" ? (

Loom video URL

@@ -753,405 +330,13 @@ export const ImportLoomPage = ({
- ) : ( -
- {!csvData && ( - <> -
-
-
- -
-
-

- First time? Start with our template -

-

- Two columns required:{" "} - - loom_video_url - {" "} - and{" "} - - user_email - - . Add{" "} - - space_name - {" "} - to place videos in spaces. Emails that are not members - yet will be added without an email invite. -

-
-
- -
+ ) : orgId ? ( + + ) : null} -
{ - event.preventDefault(); - setIsDragOver(true); - }} - onDragLeave={() => setIsDragOver(false)} - onDrop={handleDrop} - className={clsx( - "relative flex flex-col items-center justify-center w-full rounded-xl border-2 border-dashed transition-all duration-200 py-14 px-8", - isDragOver - ? "border-blue-10 bg-blue-3" - : "border-gray-4 bg-gray-1 hover:border-gray-6 hover:bg-gray-2", - )} - > -
-
- -
-
-

- Drag and drop your CSV here -

-

- Or browse your computer to upload a file. -

-
- -
-
- - )} - - - - {csvData && ( -
-
-
-
- -
-
-

- {csvData.fileName} -

-

- {csvData.rows.length}{" "} - {pluralize(csvData.rows.length, "row", "rows")} detected -

-
-
- -
- -
-
-

- Map columns -

-
- - setMapping((current) => ({ - ...current, - loomUrl: value, - })) - } - /> - - setMapping((current) => ({ - ...current, - userEmail: value, - })) - } - /> - - setMapping((current) => ({ - ...current, - spaceName: value, - })) - } - /> -
- {selectedColumnsConflict && ( -

- Choose different columns for each mapped field. -

- )} -
- -
- - - -
- - {csvLimitExceeded && ( -
- -
-

- CSV imports are limited to {MAX_LOOM_CSV_IMPORT_ROWS}{" "} - videos at a time. -

-

- Split this file into smaller batches or{" "} - - contact support - {" "} - to raise the limit. -

-
-
- )} - - {previewRows.length > 0 && ( -
- - - - Row - Loom URL - User email - Space - Status - - - - {previewRows.map((row) => { - const valid = - isLoomUrl(row.loomUrl) && - isEmail(row.userEmail) && - isValidSpaceName(row.spaceName); - return ( - - - {row.rowNumber} - - - {row.loomUrl || "—"} - - {row.userEmail || "—"} - {row.spaceName || "—"} - - - - - ); - })} - -
- {mappedRows.length > previewRows.length && ( -
- Showing {previewRows.length} of {mappedRows.length}{" "} - mapped rows. -
- )} -
- )} - -
- - -
-
-
- )} - - {result && ( -
-
-
-

- Import results -

-

- {result.importedCount}{" "} - {pluralize(result.importedCount, "started", "started")},{" "} - {result.failedCount}{" "} - {pluralize(result.failedCount, "failed", "failed")} -

-
-
- - {result.failedCount > 0 && ( - - )} -
-
-
- - - - Row - User email - Space - Status - - - - {result.results.map((row) => ( - - - {row.rowNumber} - - {row.userEmail || "—"} - {row.spaceName || "—"} - - {row.error || (row.success ? "Started" : "Failed")} - - - ))} - -
-
-
- )} -
- )} + {mode === "csv" && } - - - } - > - Start CSV import - -
- {readyRows.length} {pluralize(readyRows.length, "video", "videos")}{" "} - will be imported for existing members or newly added users in - batches of {LOOM_CSV_BATCH_SIZE}. - {readyRows.some((row) => row.spaceName) && ( - - Rows with a space name will be added to that space. Missing - spaces will be created. - - )} - {invalidRows > 0 && ( - - {invalidRows} {pluralize(invalidRows, "row", "rows")} will be - skipped because the Loom URL, email, or space name is invalid. - - )} -
- - - - -
-
- {label} ); - -const MappingField = ({ - label, - value, - options, - optional = false, - onValueChange, -}: { - label: string; - value: string | undefined; - options: { value: string; label: string }[]; - optional?: boolean; - onValueChange: (value: string | undefined) => void; -}) => { - const fieldOptions = optional - ? [{ value: OPTIONAL_COLUMN_VALUE, label: "Do not import" }, ...options] - : options; - - return ( -
-

{label}

- setQuery(event.target.value)} + placeholder="Search titles, owners, links" + aria-label="Search videos" + className="h-8 w-full rounded-full border border-gray-4 bg-gray-2 px-3 text-sm text-gray-12 outline-none transition-colors placeholder:text-gray-9 focus:border-blue-8 sm:w-56" + /> + {canRetry && ( + + )} + {settled > 0 && + job.status !== "completed" && + job.status !== "cancelled" && ( + + )} + {canCancel && ( + + )} +
+ + + + +

+ AI titles, summaries and chapters for imported videos are made the + first time each video is opened. Need more than{" "} + {numberFormat.format(2000)} videos? Start another import with the next + CSV, they run side by side. +

+ + + + setCancelOpen(false)} + /> + + ); +}; diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx new file mode 100644 index 00000000000..1f1d7103378 --- /dev/null +++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/import-list.tsx @@ -0,0 +1,297 @@ +"use client"; + +import clsx from "clsx"; +import Link from "next/link"; +import { + memo, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { + type LoomImportDisplayStatus, + type LoomImportItemView, + loomImportStageLabel, +} from "@/lib/loom-import/status"; + +export const ROW_HEIGHT = 64; +const OVERSCAN = 8; + +const dateFormat = new Intl.DateTimeFormat("en-US", { + day: "numeric", + month: "short", + year: "numeric", +}); + +export function formatDuration(seconds: number | null | undefined) { + if (!seconds || seconds <= 0) return null; + const total = Math.round(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}` + : `${minutes}:${String(secs).padStart(2, "0")}`; +} + +function shortLoomUrl(url: string) { + return url.replace(/^https?:\/\/(www\.)?/, ""); +} + +export function useVirtualWindow(count: number, viewport: number) { + const ref = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const frame = useRef(null); + + const onScroll = useCallback(() => { + if (frame.current !== null) return; + frame.current = window.requestAnimationFrame(() => { + frame.current = null; + setScrollTop(ref.current?.scrollTop ?? 0); + }); + }, []); + + useEffect( + () => () => { + if (frame.current !== null) window.cancelAnimationFrame(frame.current); + }, + [], + ); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const max = Math.max(0, count * ROW_HEIGHT - viewport); + if (element.scrollTop > max) { + element.scrollTop = max; + setScrollTop(max); + } + }, [count, viewport]); + + const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const end = Math.min( + count, + Math.ceil((scrollTop + viewport) / ROW_HEIGHT) + OVERSCAN, + ); + return { ref, onScroll, start, end }; +} + +const STATUS_TONE: Record = { + checking: "text-gray-10", + ready: "text-gray-11", + queued: "text-gray-10", + importing: "text-blue-11", + imported: "text-[#218358] dark:text-[#3dd68c]", + failed: "text-red-11", + skipped: "text-gray-10", + cancelled: "text-gray-9", +}; + +const STATUS_LABEL: Record = { + checking: "Checking", + ready: "Ready", + queued: "Queued", + importing: "Importing", + imported: "In Cap", + failed: "Failed", + skipped: "Skipped", + cancelled: "Cancelled", +}; + +const Tick = () => ( + +); + +const StatusCell = ({ item }: { item: LoomImportItemView }) => { + const tone = STATUS_TONE[item.status]; + if (item.status === "importing") { + const progress = item.progress ?? 0; + return ( +
+ + {loomImportStageLabel(item.stage)} + +
+
+
+
+ ); + } + + if (item.status === "imported" && item.videoId) { + return ( + + + {STATUS_LABEL.imported} + + ); + } + + if (item.status === "skipped" && item.videoId) { + return ( + + Already in Cap + + Open it + + + ); + } + + return ( +
+ + {item.status === "checking" && ( + + )} + {STATUS_LABEL[item.status]} + + {item.error && ( + + {item.error} + + )} +
+ ); +}; + +const Thumb = ({ src }: { src: string | null }) => ( +
+ {src && ( + + )} +
+); + +export const ImportRow = memo(function ImportRow({ + item, + showOwner, +}: { + item: LoomImportItemView; + showOwner: boolean; +}) { + const meta = [ + item.recordedAt + ? `Recorded ${dateFormat.format(new Date(item.recordedAt))}` + : null, + formatDuration(item.duration), + showOwner ? item.email : null, + item.space, + ].filter(Boolean); + return ( +
+ +
+

+ {item.title ?? shortLoomUrl(item.url)} +

+

+ Row {item.row} + {meta.length > 0 && ` · ${meta.join(" · ")}`} +

+
+
+ +
+
+ ); +}); + +export const VirtualImportList = ({ + items, + viewport, + showOwner, + empty, +}: { + items: LoomImportItemView[]; + viewport: number; + showOwner: boolean; + empty: ReactNode; +}) => { + const { ref, onScroll, start, end } = useVirtualWindow( + items.length, + viewport, + ); + const height = Math.min(viewport, Math.max(items.length, 1) * ROW_HEIGHT); + + if (items.length === 0) { + return ( +
+ {empty} +
+ ); + } + + return ( +
+
    + {items.slice(start, end).map((item, index) => ( +
  • + +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx new file mode 100644 index 00000000000..1a4627ca545 --- /dev/null +++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/page.tsx @@ -0,0 +1,35 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { getLoomImportSnapshot } from "@/lib/loom-import/snapshot"; +import { LoomImportJobView } from "./LoomImportJobView"; + +export const metadata: Metadata = { + title: "Loom import — Cap", +}; + +export default async function Page({ + params, + searchParams, +}: { + params: Promise<{ jobId: string }>; + searchParams: Promise>; +}) { + const [{ jobId }, query, user] = await Promise.all([ + params, + searchParams, + getCurrentUser(), + ]); + if (!user) redirect("/login"); + + const snapshot = await getLoomImportSnapshot({ jobId, userId: user.id }); + if (!snapshot) notFound(); + + return ( + + ); +} diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx b/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx new file mode 100644 index 00000000000..728ea042dfa --- /dev/null +++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/upgrade-panel.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { Button } from "@cap/ui"; +import NumberFlow from "@number-flow/react"; +import { useMutation } from "@tanstack/react-query"; +import clsx from "clsx"; +import { useCurrency } from "hooks/useCurrency"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { useStripeContext } from "@/app/Layout/StripeContext"; +import { UpgradeModal } from "@/components/UpgradeModal"; +import { PRICING } from "@/data/pricing"; +import { Boil, Doodle, delay } from "../_components/doodles"; + +const numberFormat = new Intl.NumberFormat("en-US"); +const ANNUAL_SAVINGS = Math.round( + (1 - PRICING.pro.annualPerMonth / PRICING.pro.monthly) * 100, +); + +export function formatHours(seconds: number) { + if (seconds <= 0) return "0m"; + const hours = Math.floor(seconds / 3600); + const minutes = Math.round((seconds % 3600) / 60); + if (hours === 0) return `${Math.max(minutes, 1)}m`; + return minutes > 0 + ? `${numberFormat.format(hours)}h ${minutes}m` + : `${numberFormat.format(hours)}h`; +} + +const PERKS = [ + "Every video, any length, unlimited storage", + "Original titles and recording dates kept", + "AI summaries and chapters the first time a video is watched", + "Custom domain, passwords and viewer analytics", +]; + +const InkCheck = ({ at }: { at: number }) => ( + +); + +export const UpgradePanel = ({ + jobId, + videoCount, + totalDuration, + owners, + thumbs, + confirming, + canStart, + starting, + onStart, +}: { + jobId: string; + videoCount: number; + totalDuration: number; + owners: number; + thumbs: string[]; + confirming: boolean; + canStart: boolean; + starting: boolean; + onStart: () => void; +}) => { + const stripe = useStripeContext(); + const { currency } = useCurrency(); + const [annual, setAnnual] = useState(true); + const [compareOpen, setCompareOpen] = useState(false); + const titleId = useId(); + const perMonth = annual ? PRICING.pro.annualPerMonth : PRICING.pro.monthly; + + const checkout = useMutation({ + mutationFn: async () => { + const response = await fetch("/api/settings/billing/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + priceId: stripe.plans[annual ? "yearly" : "monthly"], + quantity: 1, + returnTo: `/dashboard/import/loom/${jobId}`, + }), + }); + const data = (await response.json().catch(() => ({}))) as { + url?: string; + subscription?: boolean; + }; + if (data.subscription) { + window.location.reload(); + return; + } + if (!data.url) throw new Error("Checkout unavailable"); + window.location.href = data.url; + }, + onError: () => { + toast.error("We couldn't open checkout. Please try again."); + }, + }); + + if (confirming || canStart) { + return ( +
+ +
+

+ {canStart ? "You're on Cap Pro" : "Confirming your upgrade"} +

+

+ {canStart + ? `${numberFormat.format(videoCount)} videos are checked and ready to copy into Cap.` + : "This usually takes a few seconds. Your import starts on its own once it's done."} +

+
+ {canStart && ( + + )} +
+ ); + } + + return ( +
+
+
+ +
+

+ Your Loom library is ready to move +

+

+ We checked every link. Upgrade to Cap Pro and we'll copy them into + Cap for you, right where your team expects them. +

+
+
+ +
+ {[ + { + value: numberFormat.format(videoCount), + label: videoCount === 1 ? "video" : "videos", + }, + { value: formatHours(totalDuration), label: "of recordings" }, + { + value: numberFormat.format(owners), + label: owners === 1 ? "owner" : "owners", + }, + ].map((stat) => ( +
+
{stat.label}
+
+ {stat.value} +
+
+ ))} +
+ + {thumbs.length > 0 && ( +
+ {thumbs.slice(0, 12).map((thumb, index) => ( +
= 8 && "hidden sm:block", + )} + style={delay(0.05 * index)} + > + +
+ ))} +
+ )} +
+ +
+
+

Cap Pro

+
+ {[ + { value: false, label: "Monthly" }, + { value: true, label: `Yearly · save ${ANNUAL_SAVINGS}%` }, + ].map((option) => ( + + ))} +
+
+ +
+ + + per user / month{annual ? ", billed yearly" : ""} + +
+ +
    + {PERKS.map((perk, index) => ( +
  • + + {perk} +
  • + ))} +
+ +
+ +

+ Cancel anytime. Your import starts as soon as you're back. +

+ +
+
+ +
+ ); +}; diff --git a/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts new file mode 100644 index 00000000000..20b1b5c3890 --- /dev/null +++ b/apps/web/app/(org)/dashboard/import/loom/[jobId]/use-loom-import-job.ts @@ -0,0 +1,187 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + isLoomImportJobActive, + type LoomImportItemView, + type LoomImportSnapshot, +} from "@/lib/loom-import/status"; + +const ACTIVE_POLL_MS = 1500; +const HIDDEN_POLL_MS = 15_000; +const UPGRADE_POLL_MS = 2500; +const RATE_WINDOW_MS = 90_000; +const RATE_MIN_SPAN_MS = 10_000; + +export type LoomImportSummary = Omit< + LoomImportSnapshot, + "items" | "cursor" | "full" +>; + +type RateSample = { at: number; settled: number }; + +function toSummary(snapshot: LoomImportSnapshot): LoomImportSummary { + return { + job: snapshot.job, + counts: snapshot.counts, + totalDuration: snapshot.totalDuration, + importedDuration: snapshot.importedDuration, + owners: snapshot.owners, + }; +} + +function settledCount(summary: LoomImportSummary) { + const { imported, failed, skipped, cancelled } = summary.counts; + return imported + failed + skipped + cancelled; +} + +export function mergeLoomImportItems( + map: Map, + order: string[], + snapshot: LoomImportSnapshot, +) { + let nextOrder = order; + if (snapshot.full) { + map.clear(); + nextOrder = snapshot.items.map((item) => item.id); + } + let changed = snapshot.full; + for (const item of snapshot.items) { + const previous = map.get(item.id); + if ( + !previous || + previous.v !== item.v || + previous.status !== item.status || + previous.progress !== item.progress || + previous.stage !== item.stage + ) { + map.set(item.id, item); + changed = true; + } + } + return { order: nextOrder, changed }; +} + +export function useLoomImportJob( + initial: LoomImportSnapshot, + { watchForUpgrade }: { watchForUpgrade: boolean }, +) { + const itemsRef = useRef | null>(null); + const orderRef = useRef([]); + const cursorRef = useRef(initial.cursor); + const samplesRef = useRef([]); + if (itemsRef.current === null) { + itemsRef.current = new Map(initial.items.map((item) => [item.id, item])); + orderRef.current = initial.items.map((item) => item.id); + } + + const [items, setItems] = useState(initial.items); + const [summary, setSummary] = useState(() => + toSummary(initial), + ); + const [rate, setRate] = useState(null); + + const apply = useCallback((snapshot: LoomImportSnapshot) => { + const map = itemsRef.current; + if (!map) return; + const merged = mergeLoomImportItems(map, orderRef.current, snapshot); + orderRef.current = merged.order; + cursorRef.current = Math.max(cursorRef.current, snapshot.cursor); + + const next = toSummary(snapshot); + const now = Date.now(); + const samples = samplesRef.current; + samples.push({ at: now, settled: settledCount(next) }); + while ( + samples.length > 2 && + (samples[0]?.at ?? now) < now - RATE_WINDOW_MS + ) { + samples.shift(); + } + const first = samples[0]; + const last = samples[samples.length - 1]; + const span = first && last ? last.at - first.at : 0; + const perMinute = + first && last && span >= RATE_MIN_SPAN_MS + ? ((last.settled - first.settled) / span) * 60_000 + : 0; + + setSummary(next); + setRate(perMinute > 0 ? perMinute : null); + if (merged.changed) { + const list: LoomImportItemView[] = []; + for (const id of merged.order) { + const item = map.get(id); + if (item) list.push(item); + } + setItems(list); + } + }, []); + + const fetchSnapshot = useCallback( + async (full = false) => { + const params = new URLSearchParams({ jobId: initial.job.id }); + if (!full) params.set("since", String(cursorRef.current)); + const response = await fetch(`/api/import/loom/jobs?${params}`, { + cache: "no-store", + credentials: "same-origin", + }); + if (!response.ok) throw new Error(`Snapshot failed: ${response.status}`); + apply((await response.json()) as LoomImportSnapshot); + }, + [apply, initial.job.id], + ); + + const status = summary.job.status; + const shouldPoll = + isLoomImportJobActive(status) || + (status === "awaiting_upgrade" && watchForUpgrade); + + useEffect(() => { + if (!shouldPoll) return; + let stopped = false; + let timer: number | undefined; + let hiddenAt: number | null = null; + + const schedule = () => { + if (stopped) return; + const wait = document.hidden + ? HIDDEN_POLL_MS + : status === "awaiting_upgrade" + ? UPGRADE_POLL_MS + : ACTIVE_POLL_MS; + timer = window.setTimeout(tick, wait); + }; + + const tick = () => { + fetchSnapshot() + .catch(() => undefined) + .finally(schedule); + }; + + const onVisibility = () => { + if (document.hidden) { + hiddenAt = Date.now(); + return; + } + const full = hiddenAt !== null && Date.now() - hiddenAt > 60_000; + hiddenAt = null; + window.clearTimeout(timer); + fetchSnapshot(full) + .catch(() => undefined) + .finally(schedule); + }; + + document.addEventListener("visibilitychange", onVisibility); + schedule(); + return () => { + stopped = true; + window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [shouldPoll, status, fetchSnapshot]); + + const refresh = useCallback(() => fetchSnapshot(true), [fetchSnapshot]); + + return { summary, items, rate, refresh }; +} diff --git a/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx new file mode 100644 index 00000000000..95cae03dbf3 --- /dev/null +++ b/apps/web/app/(org)/dashboard/import/loom/_components/bulk-import.tsx @@ -0,0 +1,668 @@ +"use client"; + +import { Button, Select } from "@cap/ui"; +import type { Organisation } from "@cap/web-domain"; +import clsx from "clsx"; +import { useRouter } from "next/navigation"; +import { + type ChangeEvent, + type DragEvent, + useId, + useMemo, + useRef, + useState, + useTransition, +} from "react"; +import { toast } from "sonner"; +import { createLoomImportJobAction } from "@/actions/loom-import"; +import { + buildLoomImportPlan, + type CsvTable, + countFilledRows, + detectLoomImportMapping, + LOOM_CSV_TEMPLATE, + LOOM_IMPORT_ISSUE_LABELS, + LOOM_IMPORT_MAX_ROWS, + type LoomImportField, + type LoomImportMapping, + parseCsv, + tableFromPastedLinks, +} from "@/lib/loom-import/csv"; +import { Doodle, InkArrow, StepMark } from "./doodles"; +import { HowImportWorks } from "./how-import-works"; +import "./loom-import.css"; + +const NOT_IN_FILE = "__not_in_file__"; +const MAX_FILE_BYTES = 8 * 1024 * 1024; +const numberFormat = new Intl.NumberFormat("en-US"); +const plural = (count: number, one: string, many: string) => + `${numberFormat.format(count)} ${count === 1 ? one : many}`; + +type Source = { + name: string; + table: CsvTable; + pasted: boolean; +}; + +const GUIDE = [ + { + doodle: "sheet" as const, + title: "Get your list from Loom", + body: "Admins can export every video from Settings, Workspace, Data, Export. Or just copy your Loom links.", + }, + { + doodle: "drop" as const, + title: "Drop it here", + body: "We find the Loom links, owners and spaces on our own. You check them before anything starts.", + }, + { + doodle: "move" as const, + title: "Follow along live", + body: "Videos keep their titles and recording dates. Close the tab any time, the import keeps going.", + }, +]; + +export const ImportGuide = ({ onHowItWorks }: { onHowItWorks: () => void }) => ( +
+
    + {GUIDE.map((step, index) => ( +
  1. +
    + +
    +
    + +
    +

    {step.title}

    +

    + {step.body} +

    +
    +
    +
  2. + ))} +
+ +
+); + +function downloadTemplate() { + const blob = new Blob([LOOM_CSV_TEMPLATE], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "cap-loom-import-template.csv"; + link.click(); + URL.revokeObjectURL(url); +} + +const SourcePicker = ({ onSource }: { onSource: (source: Source) => void }) => { + const inputRef = useRef(null); + const [isOver, setIsOver] = useState(false); + const [pasteOpen, setPasteOpen] = useState(false); + const [pasteText, setPasteText] = useState(""); + const pasteId = useId(); + + const readFile = async (file: File) => { + if (file.size > MAX_FILE_BYTES) { + toast.error("That file is over 8 MB. Split it into smaller CSVs."); + return; + } + try { + const table = parseCsv(await file.text()); + if (countFilledRows(table) === 0) { + toast.error("That CSV has a header but no rows."); + return; + } + onSource({ name: file.name, table, pasted: false }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "We couldn't read that CSV.", + ); + } + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + setIsOver(false); + const file = event.dataTransfer.files[0]; + if (file) void readFile(file); + }; + + const onFile = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) void readFile(file); + event.target.value = ""; + }; + + const usePasted = () => { + const table = tableFromPastedLinks(pasteText); + if (table.rows.length === 0) { + toast.error("We couldn't find any Loom links in that text."); + return; + } + onSource({ name: "Pasted Loom links", table, pasted: true }); + }; + + return ( +
+
{ + event.preventDefault(); + setIsOver(true); + }} + onDragLeave={() => setIsOver(false)} + onDrop={onDrop} + className={clsx( + "li-dropzone relative flex flex-col items-center gap-4 rounded-2xl px-6 py-10 text-center transition-colors duration-200 sm:flex-row sm:gap-8 sm:px-10 sm:text-left", + isOver ? "is-over bg-blue-2" : "bg-gray-1", + )} + > + +
+

+ {isOver ? "Drop to read your CSV" : "Drop your Loom CSV here"} +

+

+ Any CSV with Loom links works, including Loom's own export. Up to{" "} + {numberFormat.format(LOOM_IMPORT_MAX_ROWS)} videos per file. +

+
+ + +
+
+ +
+ + {pasteOpen && ( +
+ +