From aefe0e609fa622361c07035afa9e44700a4ba4fa Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 01/36] feat(database): add index for owner non-screenshot video counts --- .../migrations/0024_late_the_spike.sql | 1 + .../migrations/meta/0024_snapshot.json | 3193 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/schema.ts | 5 + 4 files changed, 3206 insertions(+) create mode 100644 packages/database/migrations/0024_late_the_spike.sql create mode 100644 packages/database/migrations/meta/0024_snapshot.json diff --git a/packages/database/migrations/0024_late_the_spike.sql b/packages/database/migrations/0024_late_the_spike.sql new file mode 100644 index 00000000000..0145511df23 --- /dev/null +++ b/packages/database/migrations/0024_late_the_spike.sql @@ -0,0 +1 @@ +CREATE INDEX `owner_screenshot_created_idx` ON `videos` (`ownerId`,`isScreenshot`,`createdAt`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0024_snapshot.json b/packages/database/migrations/meta/0024_snapshot.json new file mode 100644 index 00000000000..e1940b63181 --- /dev/null +++ b/packages/database/migrations/meta/0024_snapshot.json @@ -0,0 +1,3193 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "54a6349f-eb75-4968-b3bf-f324c53bd16a", + "prevId": "a9518362-ccdf-4f5d-bfdc-68e8f45bcba3", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_in": { + "name": "refresh_token_expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "tempColumn": { + "name": "tempColumn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "provider_account_id_idx": { + "name": "provider_account_id_idx", + "columns": ["providerAccountId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accounts_id": { + "name": "accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth_api_keys": { + "name": "auth_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_api_keys_id": { + "name": "auth_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "parentCommentId": { + "name": "parentCommentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_type_created_idx": { + "name": "video_type_created_idx", + "columns": ["videoId", "type", "createdAt", "id"], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": ["authorId"], + "isUnique": false + }, + "parent_comment_id_idx": { + "name": "parent_comment_id_idx", + "columns": ["parentCommentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_api_keys": { + "name": "developer_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyType": { + "name": "keyType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyPrefix": { + "name": "keyPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedKey": { + "name": "encryptedKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "key_hash_idx": { + "name": "key_hash_idx", + "columns": ["keyHash"], + "isUnique": true + }, + "app_key_type_idx": { + "name": "app_key_type_idx", + "columns": ["appId", "keyType"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_api_keys_appId_developer_apps_id_fk": { + "name": "developer_api_keys_appId_developer_apps_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_keys_id": { + "name": "developer_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_app_domains": { + "name": "developer_app_domains", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_app_domains_appId_developer_apps_id_fk": { + "name": "developer_app_domains_appId_developer_apps_id_fk", + "tableFrom": "developer_app_domains", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_app_domains_id": { + "name": "developer_app_domains_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_domain_unique": { + "name": "app_domain_unique", + "columns": ["appId", "domain"] + } + }, + "checkConstraint": {} + }, + "developer_apps": { + "name": "developer_apps", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_deleted_idx": { + "name": "owner_deleted_idx", + "columns": ["ownerId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "developer_apps_id": { + "name": "developer_apps_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_accounts": { + "name": "developer_credit_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceMicroCredits": { + "name": "balanceMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePaymentMethodId": { + "name": "stripePaymentMethodId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoTopUpEnabled": { + "name": "autoTopUpEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autoTopUpThresholdMicroCredits": { + "name": "autoTopUpThresholdMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoTopUpAmountCents": { + "name": "autoTopUpAmountCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_id_unique": { + "name": "app_id_unique", + "columns": ["appId"], + "isUnique": true + } + }, + "foreignKeys": { + "developer_credit_accounts_appId_developer_apps_id_fk": { + "name": "developer_credit_accounts_appId_developer_apps_id_fk", + "tableFrom": "developer_credit_accounts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_accounts_id": { + "name": "developer_credit_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_transactions": { + "name": "developer_credit_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amountMicroCredits": { + "name": "amountMicroCredits", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceAfterMicroCredits": { + "name": "balanceAfterMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceType": { + "name": "referenceType", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "account_type_created_idx": { + "name": "account_type_created_idx", + "columns": ["accountId", "type", "createdAt"], + "isUnique": false + }, + "account_ref_dedup_idx": { + "name": "account_ref_dedup_idx", + "columns": ["accountId", "referenceId", "referenceType"], + "isUnique": false + } + }, + "foreignKeys": { + "dev_credit_txn_account_fk": { + "name": "dev_credit_txn_account_fk", + "tableFrom": "developer_credit_transactions", + "tableTo": "developer_credit_accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_transactions_id": { + "name": "developer_credit_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_daily_storage_snapshots": { + "name": "developer_daily_storage_snapshots", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshotDate": { + "name": "snapshotDate", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalDurationMinutes": { + "name": "totalDurationMinutes", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "videoCount": { + "name": "videoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "microCreditsCharged": { + "name": "microCreditsCharged", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_daily_storage_snapshots_appId_developer_apps_id_fk": { + "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk", + "tableFrom": "developer_daily_storage_snapshots", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_daily_storage_snapshots_id": { + "name": "developer_daily_storage_snapshots_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_date_unique": { + "name": "app_date_unique", + "columns": ["appId", "snapshotDate"] + } + }, + "checkConstraint": {} + }, + "developer_videos": { + "name": "developer_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_created_idx": { + "name": "app_created_idx", + "columns": ["appId", "createdAt"], + "isUnique": false + }, + "app_user_idx": { + "name": "app_user_idx", + "columns": ["appId", "externalUserId"], + "isUnique": false + }, + "app_deleted_idx": { + "name": "app_deleted_idx", + "columns": ["appId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_videos_appId_developer_apps_id_fk": { + "name": "developer_videos_appId_developer_apps_id_fk", + "tableFrom": "developer_videos", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_videos_id": { + "name": "developer_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": ["parentId"], + "isUnique": false + }, + "space_id_idx": { + "name": "space_id_idx", + "columns": ["spaceId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "folders_id": { + "name": "folders_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "imported_videos": { + "name": "imported_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_conversations": { + "name": "messenger_conversations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverByUserId": { + "name": "takeoverByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverAt": { + "name": "takeoverAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_last_message_idx": { + "name": "user_last_message_idx", + "columns": ["userId", "lastMessageAt"], + "isUnique": false + }, + "anonymous_last_message_idx": { + "name": "anonymous_last_message_idx", + "columns": ["anonymousId", "lastMessageAt"], + "isUnique": false + }, + "mode_last_message_idx": { + "name": "mode_last_message_idx", + "columns": ["mode", "lastMessageAt"], + "isUnique": false + }, + "updated_at_idx": { + "name": "updated_at_idx", + "columns": ["updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "messenger_conversations_id": { + "name": "messenger_conversations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_messages": { + "name": "messenger_messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "conversation_created_at_idx": { + "name": "conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + }, + "role_created_at_idx": { + "name": "role_created_at_idx", + "columns": ["role", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "messenger_messages_conversationId_messenger_conversations_id_fk": { + "name": "messenger_messages_conversationId_messenger_conversations_id_fk", + "tableFrom": "messenger_messages", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_messages_id": { + "name": "messenger_messages_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientId": { + "name": "recipientId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": ["orgId"], + "isUnique": false + }, + "type_idx": { + "name": "type_idx", + "columns": ["type"], + "isUnique": false + }, + "read_at_idx": { + "name": "read_at_idx", + "columns": ["readAt"], + "isUnique": false + }, + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + }, + "recipient_read_idx": { + "name": "recipient_read_idx", + "columns": ["recipientId", "readAt"], + "isUnique": false + }, + "recipient_created_idx": { + "name": "recipient_created_idx", + "columns": ["recipientId", "createdAt"], + "isUnique": false + }, + "dedup_key_idx": { + "name": "dedup_key_idx", + "columns": ["dedupKey"], + "isUnique": true + }, + "type_recipient_created_idx": { + "name": "type_recipient_created_idx", + "columns": ["type", "recipientId", "createdAt"], + "isUnique": false + }, + "type_recipient_video_created_idx": { + "name": "type_recipient_video_created_idx", + "columns": ["type", "recipientId", "videoId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": ["invitedEmail"], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": ["invitedByUserId"], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tombstoneAt": { + "name": "tombstoneAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedEmailDomain": { + "name": "allowedEmailDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": ["ownerId", "tombstoneAt"], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": ["customDomain"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketName": { + "name": "bucketName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessKeyId": { + "name": "accessKeyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aws')" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_organization_idx": { + "name": "owner_organization_idx", + "columns": ["ownerId", "organizationId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "s3_buckets_id": { + "name": "s3_buckets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "session_token_idx": { + "name": "session_token_idx", + "columns": ["sessionToken"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_videos": { + "name": "shared_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedByUserId": { + "name": "sharedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "shared_by_user_id_idx": { + "name": "shared_by_user_id_idx", + "columns": ["sharedByUserId"], + "isUnique": false + }, + "video_id_organization_id_idx": { + "name": "video_id_organization_id_idx", + "columns": ["videoId", "organizationId"], + "isUnique": false + }, + "video_id_folder_id_idx": { + "name": "video_id_folder_id_idx", + "columns": ["videoId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "shared_videos_id": { + "name": "shared_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_members_id": { + "name": "space_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "space_id_user_id_unique": { + "name": "space_id_user_id_unique", + "columns": ["spaceId", "userId"] + } + }, + "checkConstraint": {} + }, + "space_videos": { + "name": "space_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedById": { + "name": "addedById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "added_by_id_idx": { + "name": "added_by_id_idx", + "columns": ["addedById"], + "isUnique": false + }, + "space_id_video_id_idx": { + "name": "space_id_video_id_idx", + "columns": ["spaceId", "videoId"], + "isUnique": false + }, + "space_id_folder_id_idx": { + "name": "space_id_folder_id_idx", + "columns": ["spaceId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_videos_id": { + "name": "space_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary": { + "name": "primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "privacy": { + "name": "privacy", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Private'" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spaces_id": { + "name": "spaces_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_integrations": { + "name": "storage_integrations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "encryptedConfig": { + "name": "encryptedConfig", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "googleDriveAccessToken": { + "name": "googleDriveAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveAccessTokenExpiresAt": { + "name": "googleDriveAccessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseId": { + "name": "googleDriveTokenRefreshLeaseId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseExpiresAt": { + "name": "googleDriveTokenRefreshLeaseExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveStorageQuotaCache": { + "name": "googleDriveStorageQuotaCache", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_provider_idx": { + "name": "owner_provider_idx", + "columns": ["ownerId", "provider"], + "isUnique": false + }, + "owner_active_idx": { + "name": "owner_active_idx", + "columns": ["ownerId", "active"], + "isUnique": false + }, + "organization_provider_idx": { + "name": "organization_provider_idx", + "columns": ["organizationId", "provider"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "storage_integrations_id": { + "name": "storage_integrations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_objects": { + "name": "storage_objects", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrationId": { + "name": "integrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectKey": { + "name": "objectKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "objectKeyHash": { + "name": "objectKeyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerObjectId": { + "name": "providerObjectId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadSessionUrl": { + "name": "uploadSessionUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadStatus": { + "name": "uploadStatus", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "contentType": { + "name": "contentType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentLength": { + "name": "contentLength", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "integration_key_hash_idx": { + "name": "integration_key_hash_idx", + "columns": ["integrationId", "objectKeyHash"], + "isUnique": true + }, + "integration_status_idx": { + "name": "integration_status_idx", + "columns": ["integrationId", "uploadStatus"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "storage_objects_integrationId_storage_integrations_id_fk": { + "name": "storage_objects_integrationId_storage_integrations_id_fk", + "tableFrom": "storage_objects", + "tableTo": "storage_integrations", + "columnsFrom": ["integrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "storage_objects_id": { + "name": "storage_objects_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": ["identifier"] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'My Video'" + }, + "bucket": { + "name": "bucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageIntegrationId": { + "name": "storageIntegrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"type\":\"MediaConvert\"}')" + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "effectiveCreatedAt": { + "name": "effectiveCreatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )", + "type": "stored" + } + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xStreamInfo": { + "name": "xStreamInfo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstViewEmailSentAt": { + "name": "firstViewEmailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isScreenshot": { + "name": "isScreenshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awsRegion": { + "name": "awsRegion", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "awsBucket": { + "name": "awsBucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoStartTime": { + "name": "videoStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audioStartTime": { + "name": "audioStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobId": { + "name": "jobId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobStatus": { + "name": "jobStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skipProcessing": { + "name": "skipProcessing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "owner_screenshot_created_idx": { + "name": "owner_screenshot_created_idx", + "columns": ["ownerId", "isScreenshot", "createdAt"], + "isUnique": false + }, + "is_public_idx": { + "name": "is_public_idx", + "columns": ["public"], + "isUnique": false + }, + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "storage_integration_id_idx": { + "name": "storage_integration_id_idx", + "columns": ["storageIntegrationId"], + "isUnique": false + }, + "org_owner_folder_idx": { + "name": "org_owner_folder_idx", + "columns": ["orgId", "ownerId", "folderId"], + "isUnique": false + }, + "org_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "videos_storageIntegrationId_storage_integrations_id_fk": { + "name": "videos_storageIntegrationId_storage_integrations_id_fk", + "tableFrom": "videos", + "tableTo": "storage_integrations", + "columnsFrom": ["storageIntegrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "videos_id": { + "name": "videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index cab5ef213d3..562d5e59270 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1778434170430, "tag": "0023_misty_luckman", "breakpoints": true + }, + { + "idx": 24, + "version": "5", + "when": 1778523732397, + "tag": "0024_late_the_spike", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 94563464864..e713c5ca42a 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -363,6 +363,11 @@ export const videos = mysqlTable( }, (table) => [ index("owner_id_idx").on(table.ownerId), + index("owner_screenshot_created_idx").on( + table.ownerId, + table.isScreenshot, + table.createdAt, + ), index("is_public_idx").on(table.public), index("folder_id_idx").on(table.folderId), index("storage_integration_id_idx").on(table.storageIntegrationId), From 4158d11325a137255fd3933d9a74451140fce92d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 02/36] feat(web-domain): add shareable link usage and limit error schemas --- packages/web-domain/src/Video.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 711df357328..a70434d65f7 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -92,6 +92,24 @@ export class UploadProgress extends Schema.Class( hasRawFallback: Schema.Boolean, }) {} +export const ShareableLinkUsage = Schema.Struct({ + used: Schema.Number, + limit: Schema.Number, + remaining: Schema.Number, + resetAt: Schema.String, + maxDurationSeconds: Schema.Number, +}); +export type ShareableLinkUsage = typeof ShareableLinkUsage.Type; + +export class ShareableLinkUsageLimitError extends Schema.TaggedError()( + "ShareableLinkUsageLimitError", + { + reason: Schema.Literal("shareable_link_limit", "duration_limit"), + usage: ShareableLinkUsage, + }, + HttpApiSchema.annotations({ status: 403 }), +) {} + export const UploadProgressUpdateInput = Schema.Struct({ videoId: VideoId, uploaded: Schema.Int.pipe(Schema.greaterThanOrEqualTo(0)), @@ -265,7 +283,12 @@ export class VideoRpcs extends RpcGroup.make( }).middleware(RpcAuthMiddleware), Rpc.make("VideoDuplicate", { payload: VideoId, - error: Schema.Union(NotFoundError, InternalError, PolicyDeniedError), + error: Schema.Union( + NotFoundError, + InternalError, + PolicyDeniedError, + ShareableLinkUsageLimitError, + ), }).middleware(RpcAuthMiddleware), Rpc.make("GetUploadProgress", { payload: VideoId, @@ -280,7 +303,11 @@ export class VideoRpcs extends RpcGroup.make( Rpc.make("VideoInstantCreate", { payload: InstantRecordingCreateInput, success: InstantRecordingCreateSuccess, - error: Schema.Union(InternalError, PolicyDeniedError), + error: Schema.Union( + InternalError, + PolicyDeniedError, + ShareableLinkUsageLimitError, + ), }).middleware(RpcAuthMiddleware), Rpc.make("VideoUploadProgressUpdate", { payload: UploadProgressUpdateInput, From f296f09f63bff41307ea9c14bf620e8ebab7f9d7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 03/36] feat(web-domain): add shareable link limit to Loom HTTP and workflow --- packages/web-domain/src/Loom.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web-domain/src/Loom.ts b/packages/web-domain/src/Loom.ts index 067ed396df3..0caf38a2f6d 100644 --- a/packages/web-domain/src/Loom.ts +++ b/packages/web-domain/src/Loom.ts @@ -58,6 +58,7 @@ export const ImportVideo = Workflow.make({ error: Schema.Union( DatabaseError, Video.NotFoundError, + Video.ShareableLinkUsageLimitError, S3Error, ExternalLoomError, VideoInvalidError, @@ -80,6 +81,7 @@ export class LoomHttpApi extends HttpApiGroup.make("loom") .addError(InternalServerError) .addError(PolicyDeniedError) .addError(Video.NotFoundError) + .addError(Video.ShareableLinkUsageLimitError) .addError(ExternalLoomError), ) .middleware(HttpAuthMiddleware) From 1629a4d485fccf66a70f76e5b7750ead778423e2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 04/36] feat(web-backend): add shareable link monthly quota helpers --- .../web-backend/src/ShareableLinkUsage.ts | 238 ++++++++++++++++++ packages/web-backend/src/index.ts | 1 + 2 files changed, 239 insertions(+) create mode 100644 packages/web-backend/src/ShareableLinkUsage.ts diff --git a/packages/web-backend/src/ShareableLinkUsage.ts b/packages/web-backend/src/ShareableLinkUsage.ts new file mode 100644 index 00000000000..3e33ce97201 --- /dev/null +++ b/packages/web-backend/src/ShareableLinkUsage.ts @@ -0,0 +1,238 @@ +import * as Db from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; +import { type User, Video } from "@cap/web-domain"; +import { and, count, eq, gte, lt } from "drizzle-orm"; +import type { DbClient } from "./Database.ts"; + +export const FREE_SHAREABLE_LINK_LIMIT = 30; +export const FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS = 300; + +type Transaction = Parameters[0]>[0]; +type QueryClient = DbClient | Transaction; + +export type ShareableLinkUsageSnapshot = Video.ShareableLinkUsage; + +export function getShareableLinkPeriod(now = new Date()) { + const year = now.getUTCFullYear(); + const month = now.getUTCMonth(); + const periodStart = new Date(Date.UTC(year, month, 1)); + const periodEnd = new Date(Date.UTC(year, month + 1, 1)); + + return { + periodStart, + periodEnd, + resetAt: periodEnd.toISOString(), + }; +} + +export function toShareableLinkUsageSnapshot( + used: number, + resetAt: string, +): ShareableLinkUsageSnapshot { + const normalizedUsed = Math.max(0, used); + + return { + used: normalizedUsed, + limit: FREE_SHAREABLE_LINK_LIMIT, + remaining: Math.max(0, FREE_SHAREABLE_LINK_LIMIT - normalizedUsed), + resetAt, + maxDurationSeconds: FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS, + }; +} + +export function getShareableLinkUsageLimitError({ + used, + resetAt, + durationSeconds, +}: { + used: number; + resetAt: string; + durationSeconds?: number | null; +}) { + const usage = toShareableLinkUsageSnapshot(used, resetAt); + + if ( + durationSeconds != null && + durationSeconds > FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS + ) { + return new Video.ShareableLinkUsageLimitError({ + reason: "duration_limit", + usage, + }); + } + + if (used >= FREE_SHAREABLE_LINK_LIMIT) { + return new Video.ShareableLinkUsageLimitError({ + reason: "shareable_link_limit", + usage, + }); + } + + return null; +} + +export function isShareableLinkUsageLimitError( + error: unknown, +): error is Video.ShareableLinkUsageLimitError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "ShareableLinkUsageLimitError" + ); +} + +export function getShareableLinkLimitResponse( + error: Video.ShareableLinkUsageLimitError, +) { + return { + error: "upgrade_required", + reason: error.reason, + usage: error.usage, + }; +} + +async function countCurrentPeriodShareableLinks( + client: QueryClient, + userId: User.UserId, + periodStart: Date, + periodEnd: Date, +) { + const [row] = await client + .select({ value: count() }) + .from(Db.videos) + .where( + and( + eq(Db.videos.ownerId, userId), + eq(Db.videos.isScreenshot, false), + gte(Db.videos.createdAt, periodStart), + lt(Db.videos.createdAt, periodEnd), + ), + ); + + return row?.value ?? 0; +} + +export async function getShareableLinkUsage( + client: QueryClient, + userId: User.UserId, + now = new Date(), +): Promise { + const { periodStart, periodEnd, resetAt } = getShareableLinkPeriod(now); + const used = await countCurrentPeriodShareableLinks( + client, + userId, + periodStart, + periodEnd, + ); + + return toShareableLinkUsageSnapshot(used, resetAt); +} + +async function assertCanCreateShareableLink({ + tx, + userId, + durationSeconds, + now, +}: { + tx: Transaction; + userId: User.UserId; + durationSeconds?: number | null; + now: Date; +}) { + const [owner] = await tx + .select({ + id: Db.users.id, + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, userId)) + .for("update"); + + if (!owner) throw new Error("User not found"); + if (userIsPro(owner)) return; + + const { periodStart, periodEnd, resetAt } = getShareableLinkPeriod(now); + const used = await countCurrentPeriodShareableLinks( + tx, + userId, + periodStart, + periodEnd, + ); + const limitError = getShareableLinkUsageLimitError({ + used, + resetAt, + durationSeconds, + }); + + if (limitError) throw limitError; +} + +export async function createVideoWithShareableLinkQuota({ + client, + ownerId, + isScreenshot = false, + durationSeconds, + now = new Date(), + create, +}: { + client: DbClient; + ownerId: User.UserId; + isScreenshot?: boolean; + durationSeconds?: number | null; + now?: Date; + create: (tx: Transaction) => Promise; +}) { + return client.transaction(async (tx) => { + if (!isScreenshot) + await assertCanCreateShareableLink({ + tx, + userId: ownerId, + durationSeconds, + now, + }); + + return create(tx); + }); +} + +export async function assertShareableLinkDurationAllowed({ + client, + ownerId, + isScreenshot = false, + durationSeconds, + now = new Date(), +}: { + client: DbClient; + ownerId: User.UserId; + isScreenshot?: boolean; + durationSeconds?: number | null; + now?: Date; +}) { + if ( + isScreenshot || + durationSeconds == null || + durationSeconds <= FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS + ) { + return; + } + + const [owner] = await client + .select({ + id: Db.users.id, + stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, + thirdPartyStripeSubscriptionId: Db.users.thirdPartyStripeSubscriptionId, + }) + .from(Db.users) + .where(eq(Db.users.id, ownerId)); + + if (!owner || userIsPro(owner)) return; + + const usage = await getShareableLinkUsage(client, ownerId, now); + + throw new Video.ShareableLinkUsageLimitError({ + reason: "duration_limit", + usage, + }); +} diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index 24f5bab9110..ed7a2fcad8a 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -9,6 +9,7 @@ export { Organisations } from "./Organisations/index.ts"; export { OrganisationsPolicy } from "./Organisations/OrganisationsPolicy.ts"; export * from "./Rpcs.ts"; export { S3Buckets } from "./S3Buckets/index.ts"; +export * from "./ShareableLinkUsage.ts"; export { Spaces } from "./Spaces/index.ts"; export { SpacesPolicy } from "./Spaces/SpacesPolicy.ts"; export * from "./Storage/GoogleDrive.ts"; From 4f62d0c636aee5a2aeb3799bc443cdd71f9c12a7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 05/36] feat(web-backend): enforce shareable link quota on video insert --- packages/web-backend/src/Videos/VideosRepo.ts | 86 +++++++++++-------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/packages/web-backend/src/Videos/VideosRepo.ts b/packages/web-backend/src/Videos/VideosRepo.ts index 17dec55ddf3..82f87371423 100644 --- a/packages/web-backend/src/Videos/VideosRepo.ts +++ b/packages/web-backend/src/Videos/VideosRepo.ts @@ -1,11 +1,14 @@ import { nanoId } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; -import { Video } from "@cap/web-domain"; +import { type DatabaseError, Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; -import type { MySqlInsertBase } from "drizzle-orm/mysql-core"; import { Effect, Option } from "effect"; import type { Schema } from "effect/Schema"; import { Database } from "../Database.ts"; +import { + createVideoWithShareableLinkQuota, + isShareableLinkUsageLimitError, +} from "../ShareableLinkUsage.ts"; export type CreateVideoInput = Omit< Schema.Type, @@ -37,7 +40,7 @@ export class VideosRepo extends Effect.Service()("VideosRepo", { storageIntegrationId: v.storageIntegrationId, createdAt: v.createdAt.toISOString(), updatedAt: v.updatedAt.toISOString(), - metadata: v.metadata as any, + metadata: v.metadata as Record | null, }), Option.fromNullable(video?.password), ] as const, @@ -62,45 +65,54 @@ export class VideosRepo extends Effect.Service()("VideosRepo", { Effect.gen(function* () { const id = Video.VideoId.make(nanoId()); - yield* db.use((db) => - db.transaction(async (db) => { - const promises: MySqlInsertBase[] = [ - db.insert(Db.videos).values([ - { - ...data, - id, - orgId: data.orgId, - bucket: Option.getOrNull(data.bucketId ?? Option.none()), - storageIntegrationId: Option.getOrNull( - data.storageIntegrationId ?? Option.none(), - ), - metadata: Option.getOrNull(data.metadata ?? Option.none()), - transcriptionStatus: Option.getOrNull( - data.transcriptionStatus ?? Option.none(), - ), - folderId: Option.getOrNull(data.folderId ?? Option.none()), - width: Option.getOrNull(data.width ?? Option.none()), - height: Option.getOrNull(data.height ?? Option.none()), - duration: Option.getOrNull(data.duration ?? Option.none()), - }, - ]), - ]; - - if (data.importSource) - promises.push( - db.insert(Db.importedVideos).values([ + yield* db + .use((db) => + createVideoWithShareableLinkQuota({ + client: db, + ownerId: data.ownerId, + durationSeconds: Option.getOrNull(data.duration ?? Option.none()), + create: async (db) => { + await db.insert(Db.videos).values([ { + ...data, id, orgId: data.orgId, - source: data.importSource.source, - sourceId: data.importSource.id, + bucket: Option.getOrNull(data.bucketId ?? Option.none()), + storageIntegrationId: Option.getOrNull( + data.storageIntegrationId ?? Option.none(), + ), + metadata: Option.getOrNull(data.metadata ?? Option.none()), + transcriptionStatus: Option.getOrNull( + data.transcriptionStatus ?? Option.none(), + ), + folderId: Option.getOrNull(data.folderId ?? Option.none()), + width: Option.getOrNull(data.width ?? Option.none()), + height: Option.getOrNull(data.height ?? Option.none()), + duration: Option.getOrNull(data.duration ?? Option.none()), }, - ]), - ); + ]); - await Promise.all(promises); - }), - ); + if (data.importSource) + await db.insert(Db.importedVideos).values([ + { + id, + orgId: data.orgId, + source: data.importSource.source, + sourceId: data.importSource.id, + }, + ]); + }, + }), + ) + .pipe( + Effect.mapError( + (error): DatabaseError | Video.ShareableLinkUsageLimitError => { + if (isShareableLinkUsageLimitError(error.cause)) + return error.cause; + return error; + }, + ), + ); return id; }); From 76efd38d1f051796445d2e01b7039a86cf7f6516 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 06/36] feat(web-backend): map loom create activity to shareable link limit --- packages/web-backend/src/Loom/ImportVideo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-backend/src/Loom/ImportVideo.ts b/packages/web-backend/src/Loom/ImportVideo.ts index 3d5ba9101a8..ba3a98bc4b0 100644 --- a/packages/web-backend/src/Loom/ImportVideo.ts +++ b/packages/web-backend/src/Loom/ImportVideo.ts @@ -54,7 +54,7 @@ export const LoomImportVideoLive = Loom.ImportVideo.toLayer( const { videoId, customBucketId } = yield* Activity.make({ name: "CreateVideoRecord", - error: DatabaseError, + error: Schema.Union(DatabaseError, Video.ShareableLinkUsageLimitError), success: Schema.Struct({ videoId: Video.VideoId, customBucketId: Schema.Option(S3Bucket.S3BucketId), From 6a570a478043634cb414a7099770a5f9f4d1b9f0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 07/36] feat(web): add UTC month day ordinal date formatter --- apps/web/lib/utils.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index 5fb963da834..fc7abdd4c1d 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -15,6 +15,25 @@ export function formatDate(dateString: string): string { }); } +const ordinalSuffix = (day: number) => { + const remainder = day % 100; + if (remainder >= 11 && remainder <= 13) return "th"; + if (day % 10 === 1) return "st"; + if (day % 10 === 2) return "nd"; + if (day % 10 === 3) return "rd"; + return "th"; +}; + +export function formatUtcMonthDayOrdinal(dateString: string): string { + const date = new Date(dateString); + const month = date.toLocaleDateString("en-US", { + month: "long", + timeZone: "UTC", + }); + const day = date.getUTCDate(); + return `${month} ${day}${ordinalSuffix(day)}`; +} + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } From 923a6ea0b2763294cad414735d4b863903b79581 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 08/36] feat(web): return monthly shareable link usage from billing API --- .../app/api/settings/billing/usage/route.ts | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/apps/web/app/api/settings/billing/usage/route.ts b/apps/web/app/api/settings/billing/usage/route.ts index b402b4f032d..0e6fd1e1e77 100644 --- a/apps/web/app/api/settings/billing/usage/route.ts +++ b/apps/web/app/api/settings/billing/usage/route.ts @@ -1,8 +1,11 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { videos } from "@cap/database/schema"; import { userIsPro } from "@cap/utils"; -import { count, eq } from "drizzle-orm"; +import { + getShareableLinkPeriod, + getShareableLinkUsage, + toShareableLinkUsageSnapshot, +} from "@cap/web-backend"; export const dynamic = "force-dynamic"; @@ -13,24 +16,18 @@ export async function GET() { return Response.json({ auth: false }, { status: 401 }); } - const numberOfVideos = await db() - .select({ count: count() }) - .from(videos) - .where(eq(videos.ownerId, user.id)); + const isPro = userIsPro(user); + const usage = isPro + ? toShareableLinkUsageSnapshot(0, getShareableLinkPeriod().resetAt) + : await getShareableLinkUsage(db(), user.id); - if (!numberOfVideos[0]) { - return Response.json( - { error: "Could not fetch video count" }, - { status: 500 }, - ); - } - - if (userIsPro(user)) { + if (isPro) { return Response.json( { subscription: true, videoLimit: 0, - videoCount: numberOfVideos[0].count, + videoCount: usage.used, + shareableLinkUsage: usage, }, { status: 200 }, ); @@ -38,8 +35,9 @@ export async function GET() { return Response.json( { subscription: false, - videoLimit: 25, - videoCount: numberOfVideos[0].count, + videoLimit: usage.limit, + videoCount: usage.used, + shareableLinkUsage: usage, }, { status: 200 }, ); From bfc1908a73499715a90a6bd62f2807d941e05235 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 09/36] feat(web): load shareable link usage in dashboard layout --- apps/web/app/(org)/dashboard/Contexts.tsx | 5 +++++ apps/web/app/(org)/dashboard/layout.tsx | 22 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index 44c86c4c0c0..89816861c76 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -1,6 +1,7 @@ "use client"; import { buildEnv } from "@cap/env"; +import type { ShareableLinkUsageSnapshot } from "@cap/web-backend"; import Cookies from "js-cookie"; import { redirect, usePathname } from "next/navigation"; import { createContext, useContext, useEffect, useState } from "react"; @@ -25,6 +26,7 @@ type SharedContext = { activeSpace: Spaces | null; user: CurrentUser; userCapsCount: number | null; + shareableLinkUsage: ShareableLinkUsageSnapshot; toggleSidebarCollapsed: () => void; anyNewNotifications: boolean; userPreferences: UserPreferences; @@ -64,6 +66,7 @@ export function DashboardContexts({ userCapsCount, organizationSettings, userPreferences, + shareableLinkUsage, anyNewNotifications, initialTheme, initialSidebarCollapsed, @@ -74,6 +77,7 @@ export function DashboardContexts({ activeOrganization: SharedContext["activeOrganization"]; spacesData: SharedContext["spacesData"]; userCapsCount: SharedContext["userCapsCount"]; + shareableLinkUsage: SharedContext["shareableLinkUsage"]; organizationSettings: SharedContext["organizationSettings"]; userPreferences: SharedContext["userPreferences"]; anyNewNotifications: boolean; @@ -175,6 +179,7 @@ export function DashboardContexts({ activeOrganization, spacesData, userCapsCount, + shareableLinkUsage, anyNewNotifications, userPreferences, organizationSettings, diff --git a/apps/web/app/(org)/dashboard/layout.tsx b/apps/web/app/(org)/dashboard/layout.tsx index a00a71d99e9..10e4aa00fe8 100644 --- a/apps/web/app/(org)/dashboard/layout.tsx +++ b/apps/web/app/(org)/dashboard/layout.tsx @@ -1,4 +1,11 @@ +import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; +import { userIsPro } from "@cap/utils"; +import { + getShareableLinkPeriod, + getShareableLinkUsage, + toShareableLinkUsageSnapshot, +} from "@cap/web-backend"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { AuthContextProvider } from "@/app/Layout/AuthContext"; @@ -37,15 +44,26 @@ export default async function DashboardLayout({ let organizationSettings: OrganizationSettings | null = null; let spacesData: Spaces[] = []; let anyNewNotifications = false; + const emptyShareableLinkUsage = toShareableLinkUsageSnapshot( + 0, + getShareableLinkPeriod().resetAt, + ); + let shareableLinkUsage = emptyShareableLinkUsage; let userPreferences: UserPreferences; try { - const dashboardData = await getDashboardData(user); + const [dashboardData, usage] = await Promise.all([ + getDashboardData(user), + userIsPro(user) + ? Promise.resolve(emptyShareableLinkUsage) + : getShareableLinkUsage(db(), user.id), + ]); organizationSelect = dashboardData.organizationSelect; userCapsCount = dashboardData.userCapsCount; organizationSettings = dashboardData.organizationSettings; userPreferences = dashboardData.userPreferences?.preferences || null; spacesData = dashboardData.spacesData; anyNewNotifications = dashboardData.anyNewNotifications; + shareableLinkUsage = usage; } catch (error) { console.error("Failed to load dashboard data", error); organizationSelect = []; @@ -53,6 +71,7 @@ export default async function DashboardLayout({ organizationSettings = null; spacesData = []; anyNewNotifications = false; + shareableLinkUsage = emptyShareableLinkUsage; userPreferences = null; } @@ -82,6 +101,7 @@ export default async function DashboardLayout({ initialSidebarCollapsed={sidebar === "true"} anyNewNotifications={anyNewNotifications} userPreferences={userPreferences} + shareableLinkUsage={shareableLinkUsage} referClicked={referClicked === "true"} >
From 545c79f164ba592a1d7e00a3e6af6832a4aa5e05 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:49 +0100 Subject: [PATCH 10/36] feat(web): show monthly share link quota in usage sidebar --- apps/web/components/UsageButton.tsx | 128 ++++++++++++++-------------- 1 file changed, 65 insertions(+), 63 deletions(-) diff --git a/apps/web/components/UsageButton.tsx b/apps/web/components/UsageButton.tsx index 1e00b743b38..54e56da2354 100644 --- a/apps/web/components/UsageButton.tsx +++ b/apps/web/components/UsageButton.tsx @@ -1,12 +1,13 @@ import { Button } from "@cap/ui"; import { faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Fit, Layout, useRive } from "@rive-app/react-canvas"; import clsx from "clsx"; +import { Link2 } from "lucide-react"; import Link from "next/link"; import { memo } from "react"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import { Tooltip } from "@/components/Tooltip"; +import { formatUtcMonthDayOrdinal } from "@/lib/utils"; export const UsageButton = memo( ({ @@ -16,7 +17,8 @@ export const UsageButton = memo( subscribed: boolean; toggleMobileNav?: () => void; }) => { - const { sidebarCollapsed } = useDashboardContext(); + const { setUpgradeModalOpen, shareableLinkUsage, sidebarCollapsed } = + useDashboardContext(); if (subscribed) { return ( @@ -48,72 +50,72 @@ export const UsageButton = memo( ); } - return ( - - - + const percent = Math.min( + 100, + (shareableLinkUsage.used / shareableLinkUsage.limit) * 100, ); - }, -); + const resetDate = formatUtcMonthDayOrdinal(shareableLinkUsage.resetAt); + const openUpgrade = () => { + setUpgradeModalOpen(true); + toggleMobileNav?.(); + }; -const ProRiveButton = memo( - ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { - const { setUpgradeModalOpen, sidebarCollapsed } = useDashboardContext(); - - const { rive, RiveComponent: ProRive } = useRive({ - src: "/rive/pricing.riv", - artboard: "pro", - animations: "idle", - autoplay: false, - layout: new Layout({ - fit: Fit.Cover, - }), - }); + if (sidebarCollapsed) { + return ( + + + + ); + } return ( - +
+
+
+
+
+

+ {shareableLinkUsage.remaining} left - 5 min max - resets {resetDate} +

+ +
+
); }, ); From 0afa11a2a7e992a294bfb48d94884b662d13dba5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 11/36] feat(web): enforce shareable link rules in desktop video API --- apps/web/app/api/desktop/[...route]/root.ts | 12 ++- apps/web/app/api/desktop/[...route]/video.ts | 98 ++++++++++++-------- 2 files changed, 71 insertions(+), 39 deletions(-) diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 9ee5f729d23..9de342f026f 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -9,7 +9,12 @@ import { import { buildEnv, serverEnv } from "@cap/env"; import { stripe, userIsPro } from "@cap/utils"; import { OrganizationBrandingPatchBody } from "@cap/web-api-contract"; -import { ImageUploads } from "@cap/web-backend"; +import { + getShareableLinkPeriod, + getShareableLinkUsage, + ImageUploads, + toShareableLinkUsageSnapshot, +} from "@cap/web-backend"; import { type ImageUpload, Organisation } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, eq, isNull, or } from "drizzle-orm"; @@ -417,9 +422,14 @@ app.get("/plan", withAuth, async (c) => { } } + const shareableLinkUsage = isSubscribed + ? toShareableLinkUsageSnapshot(0, getShareableLinkPeriod().resetAt) + : await getShareableLinkUsage(db(), user.id); + return c.json({ upgraded: isSubscribed, stripeSubscriptionStatus: user.stripeSubscriptionStatus, + shareableLinkUsage, }); }); diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 6ce8d6aa6f1..42414f1fc64 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -11,8 +11,14 @@ import { videoUploads, } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; -import { dub, userIsPro } from "@cap/utils"; -import { Storage } from "@cap/web-backend"; +import { dub } from "@cap/utils"; +import { + assertShareableLinkDurationAllowed, + createVideoWithShareableLinkQuota, + getShareableLinkLimitResponse, + isShareableLinkUsageLimitError, + Storage, +} from "@cap/web-backend"; import { Organisation, Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, count, eq, lte, or } from "drizzle-orm"; @@ -75,11 +81,6 @@ app.get( } = c.req.valid("query"); const user = c.get("user"); - const isCapPro = userIsPro(user); - - if (!isCapPro && durationInSecs && durationInSecs > /* 5 min */ 5 * 60) - return c.json({ error: "upgrade_required" }, { status: 403 }); - console.log("Video create request:", { recordingMode, isScreenshot, @@ -103,7 +104,17 @@ app.get( .from(videos) .where(eq(videos.id, Video.VideoId.make(videoId))); - if (video) + if (video) { + if (video.ownerId !== user.id) + return c.json({ error: "Forbidden" }, { status: 403 }); + + await assertShareableLinkDurationAllowed({ + client: db(), + ownerId: user.id, + isScreenshot: video.isScreenshot, + durationSeconds: durationInSecs, + }); + return c.json({ id: video.id, // All deprecated @@ -111,6 +122,7 @@ app.get( aws_region: "n/a", aws_bucket: "n/a", }); + } } const userOrganizations = await db() @@ -194,41 +206,49 @@ app.get( : Storage.getS3WritableAccessForUser(user.id, videoOrgId) ).pipe(runPromise); - await db() - .insert(videos) - .values({ - id: idToUse, - name: videoName, - ownerId: user.id, - orgId: videoOrgId, - source: - recordingMode === "hls" - ? { type: "local" as const } - : recordingMode === "desktopMP4" - ? { type: "desktopMP4" as const } - : recordingMode === "desktopSegments" - ? { type: "desktopSegments" as const } - : undefined, - isScreenshot, - bucket: Option.getOrNull(writable.bucketId), - storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, - duration: durationInSecs, - width, - height, - fps, - }); - const clientSupportsUploadProgress = isFromDesktopSemver( c.req, UPLOAD_PROGRESS_VERSION, ); - if (clientSupportsUploadProgress) - await db().insert(videoUploads).values({ - videoId: idToUse, - mode: "singlepart", - }); + await createVideoWithShareableLinkQuota({ + client: db(), + ownerId: user.id, + isScreenshot, + durationSeconds: durationInSecs, + create: async (tx) => { + await tx.insert(videos).values({ + id: idToUse, + name: videoName, + ownerId: user.id, + orgId: videoOrgId, + source: + recordingMode === "hls" + ? { type: "local" as const } + : recordingMode === "desktopMP4" + ? { type: "desktopMP4" as const } + : recordingMode === "desktopSegments" + ? { type: "desktopSegments" as const } + : undefined, + isScreenshot, + bucket: Option.getOrNull(writable.bucketId), + storageIntegrationId: Option.getOrNull( + writable.storageIntegrationId, + ), + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + duration: durationInSecs, + width, + height, + fps, + }); + + if (clientSupportsUploadProgress) + await tx.insert(videoUploads).values({ + videoId: idToUse, + mode: "singlepart", + }); + }, + }); if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") await dub().links.create({ @@ -283,6 +303,8 @@ app.get( aws_bucket: "n/a", }); } catch (error) { + if (isShareableLinkUsageLimitError(error)) + return c.json(getShareableLinkLimitResponse(error), { status: 403 }); console.error("Error in video create endpoint:", error); return c.json({ error: "Internal server error" }, { status: 500 }); } From 97a8ddcf6dbe497a876463ccf1e3237ce79f3240 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 12/36] feat(web): enforce shareable link limits on upload routes --- .../app/api/upload/[...route]/multipart.ts | 23 +++++++++++++-- .../upload/[...route]/recording-complete.ts | 28 ++++++++++++++++++- apps/web/app/api/upload/[...route]/signed.ts | 20 ++++++++++++- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index c4bfed4b071..830a28f52a6 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -1,8 +1,11 @@ -import { updateIfDefined } from "@cap/database"; +import { db as getDb, updateIfDefined } from "@cap/database"; import * as Db from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import { + assertShareableLinkDurationAllowed, Database, + getShareableLinkLimitResponse, + isShareableLinkUsageLimitError, makeCurrentUserLayer, provideOptionalAuth, Storage, @@ -328,6 +331,16 @@ app.post( } const [video] = maybeVideo.value; + yield* Effect.tryPromise({ + try: () => + assertShareableLinkDurationAllowed({ + client: getDb(), + ownerId: user.id, + durationSeconds: body.durationInSecs, + }), + catch: (cause) => cause, + }); + return yield* Effect.gen(function* () { const [bucket] = yield* Storage.getAccessForVideo(video); @@ -621,6 +634,12 @@ app.post( ); }).pipe( Effect.catchAll((error) => { + if (isShareableLinkUsageLimitError(error)) { + return Effect.succeed( + c.json(getShareableLinkLimitResponse(error), 403) as Response, + ); + } + console.error("Multipart upload failed:", error); return Effect.succeed( @@ -630,7 +649,7 @@ app.post( details: error instanceof Error ? error.message : String(error), }, 500, - ), + ) as Response, ); }), ); diff --git a/apps/web/app/api/upload/[...route]/recording-complete.ts b/apps/web/app/api/upload/[...route]/recording-complete.ts index 463b21b6e38..561eb75e193 100644 --- a/apps/web/app/api/upload/[...route]/recording-complete.ts +++ b/apps/web/app/api/upload/[...route]/recording-complete.ts @@ -1,7 +1,12 @@ import { db } from "@cap/database"; import * as Db from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { Storage } from "@cap/web-backend"; +import { + assertShareableLinkDurationAllowed, + getShareableLinkLimitResponse, + isShareableLinkUsageLimitError, + Storage, +} from "@cap/web-backend"; import { Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, eq, notInArray } from "drizzle-orm"; @@ -109,6 +114,23 @@ export const app = new Hono().post( ); } + const durationSeconds = manifest.video_segments.reduce( + (total, segment) => + total + Video.normalizeSegmentEntry(segment).duration, + 0, + ); + + yield* Effect.tryPromise({ + try: () => + assertShareableLinkDurationAllowed({ + client: db(), + ownerId: user.id, + isScreenshot: video.isScreenshot, + durationSeconds, + }), + catch: (cause) => cause, + }); + const videoInitUrl = yield* bucket.getSignedObjectUrl( segSource.getVideoInitKey(), { expiresIn: MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS }, @@ -289,6 +311,10 @@ export const app = new Hono().post( return c.json({ success: true, jobId: result.jobId }); } catch (error) { + if (isShareableLinkUsageLimitError(error)) { + return c.json(getShareableLinkLimitResponse(error), 403); + } + console.error("[recording-complete] Error triggering mux:", error); await db() diff --git a/apps/web/app/api/upload/[...route]/signed.ts b/apps/web/app/api/upload/[...route]/signed.ts index 63224b0f7e4..6585588ec33 100644 --- a/apps/web/app/api/upload/[...route]/signed.ts +++ b/apps/web/app/api/upload/[...route]/signed.ts @@ -1,6 +1,11 @@ import { db, updateIfDefined } from "@cap/database"; import * as Db from "@cap/database/schema"; -import { Storage } from "@cap/web-backend"; +import { + assertShareableLinkDurationAllowed, + getShareableLinkLimitResponse, + isShareableLinkUsageLimitError, + Storage, +} from "@cap/web-backend"; import { Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, eq } from "drizzle-orm"; @@ -135,6 +140,19 @@ app.post( if (video.ownerId !== user.id) return c.json({ error: "Forbidden" }, 403); const videoDomain = decodeVideo(video); + try { + await assertShareableLinkDurationAllowed({ + client: db(), + ownerId: user.id, + isScreenshot: video.isScreenshot, + durationSeconds: durationInSecs, + }); + } catch (error) { + if (isShareableLinkUsageLimitError(error)) + return c.json(getShareableLinkLimitResponse(error), 403); + throw error; + } + const contentType = fileKey.endsWith(".aac") ? "audio/aac" : fileKey.endsWith(".webm") From 17c11d887c9492928b4649c412267bb4815b626e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 13/36] feat(web): validate free duration on media server progress complete --- .../webhooks/media-server/progress/route.ts | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) 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 01407a0c1b3..7faa911892d 100644 --- a/apps/web/app/api/webhooks/media-server/progress/route.ts +++ b/apps/web/app/api/webhooks/media-server/progress/route.ts @@ -1,7 +1,12 @@ import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { Storage } from "@cap/web-backend"; +import { + assertShareableLinkDurationAllowed, + getShareableLinkLimitResponse, + isShareableLinkUsageLimitError, + Storage, +} from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect } from "effect"; @@ -86,8 +91,44 @@ export async function POST(request: NextRequest) { const dbPhase = mapPhaseToDbPhase(payload.phase); if (dbPhase === "complete") { + const [currentVideo] = await db() + .select() + .from(videos) + .where(eq(videos.id, payload.videoId as Video.VideoId)); + if (payload.metadata) { const duration = getValidDuration(payload.metadata.duration); + if (currentVideo && duration !== undefined) { + try { + await assertShareableLinkDurationAllowed({ + client: db(), + ownerId: currentVideo.ownerId, + isScreenshot: currentVideo.isScreenshot, + durationSeconds: duration, + }); + } catch (error) { + if (isShareableLinkUsageLimitError(error)) { + await db() + .update(videoUploads) + .set({ + phase: "error", + processingError: "Video exceeds free plan duration limit", + processingMessage: "Upgrade required", + updatedAt: new Date(), + }) + .where( + eq(videoUploads.videoId, payload.videoId as Video.VideoId), + ); + + return NextResponse.json(getShareableLinkLimitResponse(error), { + status: 403, + }); + } + + throw error; + } + } + await db() .update(videos) .set({ @@ -99,11 +140,6 @@ export async function POST(request: NextRequest) { .where(eq(videos.id, payload.videoId as Video.VideoId)); } - const [currentVideo] = await db() - .select() - .from(videos) - .where(eq(videos.id, payload.videoId as Video.VideoId)); - if (currentVideo?.source?.type === "desktopSegments") { await db() .update(videos) From 9ce4a1789cf5901d89f9589964ef759289181526 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 14/36] feat(web): enforce shareable quota in video server actions --- .../actions/video/create-for-processing.ts | 61 ++++++++++++------- apps/web/actions/video/upload.ts | 44 +++++++++---- 2 files changed, 71 insertions(+), 34 deletions(-) diff --git a/apps/web/actions/video/create-for-processing.ts b/apps/web/actions/video/create-for-processing.ts index 2f3d4942d44..aeac9d1ab5d 100644 --- a/apps/web/actions/video/create-for-processing.ts +++ b/apps/web/actions/video/create-for-processing.ts @@ -5,8 +5,12 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { videos, videoUploads } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; -import { dub, userIsPro } from "@cap/utils"; -import { Storage as StorageService } from "@cap/web-backend"; +import { dub } from "@cap/utils"; +import { + createVideoWithShareableLinkQuota, + isShareableLinkUsageLimitError, + Storage as StorageService, +} from "@cap/web-backend"; import { type Folder, type Organisation, @@ -45,10 +49,6 @@ export async function createVideoForServerProcessing({ if (!user) throw new Error("Unauthorized"); - if (!userIsPro(user) && duration && duration > 300) { - throw new Error("upgrade_required"); - } - await requireOrganizationAccess(user.id, orgId); const videoId = Video.VideoId.make(nanoId()); @@ -74,25 +74,39 @@ export async function createVideoForServerProcessing({ orgId, ).pipe(runPromise); - await db() - .insert(videos) - .values({ - id: videoId, - name: `Cap Upload - ${formattedDate}`, + try { + await createVideoWithShareableLinkQuota({ + client: db(), ownerId: user.id, - orgId, - source: { type: "webMP4" as const }, - bucket: Option.getOrNull(uploadResult.bucketId), - storageIntegrationId: Option.getOrNull(uploadResult.storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, - ...(folderId ? { folderId } : {}), - }); + durationSeconds: duration, + create: async (tx) => { + await tx.insert(videos).values({ + id: videoId, + name: `Cap Upload - ${formattedDate}`, + ownerId: user.id, + orgId, + source: { type: "webMP4" as const }, + bucket: Option.getOrNull(uploadResult.bucketId), + storageIntegrationId: Option.getOrNull( + uploadResult.storageIntegrationId, + ), + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + ...(duration !== undefined ? { duration } : {}), + ...(folderId ? { folderId } : {}), + }); - await db().insert(videoUploads).values({ - videoId, - phase: "uploading", - processingProgress: 0, - }); + await tx.insert(videoUploads).values({ + videoId, + phase: "uploading", + processingProgress: 0, + }); + }, + }); + } catch (error) { + if (isShareableLinkUsageLimitError(error)) + throw new Error("upgrade_required", { cause: error }); + throw error; + } if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { await dub() @@ -109,6 +123,7 @@ export async function createVideoForServerProcessing({ revalidatePath("/dashboard/caps"); revalidatePath("/dashboard/folder"); revalidatePath("/dashboard/spaces"); + revalidatePath("/dashboard"); return { id: videoId, diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 1a66aa17afb..f4dfca81266 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -5,8 +5,13 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { videos, videoUploads } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; -import { dub, userIsPro } from "@cap/utils"; -import { Storage as StorageService } from "@cap/web-backend"; +import { dub } from "@cap/utils"; +import { + assertShareableLinkDurationAllowed, + createVideoWithShareableLinkQuota, + isShareableLinkUsageLimitError, + Storage as StorageService, +} from "@cap/web-backend"; import { type Folder, type Organisation, @@ -134,9 +139,6 @@ export async function createVideoAndGetUploadUrl({ if (!user) throw new Error("Unauthorized"); try { - if (!userIsPro(user) && duration && duration > 300) - throw new Error("upgrade_required"); - await requireOrganizationAccess(user.id, orgId); const date = new Date(); @@ -153,6 +155,13 @@ export async function createVideoAndGetUploadUrl({ if (existingVideo) { if (existingVideo.ownerId !== user.id) throw new Error("Forbidden"); + await assertShareableLinkDurationAllowed({ + client: db(), + ownerId: user.id, + isScreenshot: existingVideo.isScreenshot, + durationSeconds: duration, + }); + const existingVideoDomain = Video.Video.decodeSync({ ...existingVideo, bucketId: existingVideo.bucket, @@ -210,15 +219,24 @@ export async function createVideoAndGetUploadUrl({ bucket: Option.getOrNull(bucketId), storageIntegrationId: Option.getOrNull(storageIntegrationId), public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + ...(duration !== undefined ? { duration } : {}), ...(folderId ? { folderId } : {}), }; - await db().insert(videos).values(videoData); - - if (supportsUploadProgress) - await db().insert(videoUploads).values({ - videoId: idToUse, - }); + await createVideoWithShareableLinkQuota({ + client: db(), + ownerId: user.id, + isScreenshot, + durationSeconds: duration, + create: async (tx) => { + await tx.insert(videos).values(videoData); + + if (supportsUploadProgress) + await tx.insert(videoUploads).values({ + videoId: idToUse, + }); + }, + }); if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { await dub() @@ -235,6 +253,7 @@ export async function createVideoAndGetUploadUrl({ revalidatePath("/dashboard/caps"); revalidatePath("/dashboard/folder"); revalidatePath("/dashboard/spaces"); + revalidatePath("/dashboard"); return { id: idToUse, @@ -242,6 +261,9 @@ export async function createVideoAndGetUploadUrl({ uploadTarget: upload, }; } catch (error) { + if (isShareableLinkUsageLimitError(error)) + throw new Error("upgrade_required", { cause: error }); + console.error("Error creating video and getting upload URL:", error); throw new Error( error instanceof Error ? error.message : "Failed to create video", From 2b7f130ca3bb89cca2b13951fdfce079f5977dbe Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 15/36] feat(web): enforce shareable quota in Loom import action --- apps/web/actions/loom.ts | 76 +++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index bb38ed1167d..a7d28c8859a 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -17,7 +17,11 @@ import { } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub, userIsPro } from "@cap/utils"; -import { Storage } from "@cap/web-backend"; +import { + createVideoWithShareableLinkQuota, + isShareableLinkUsageLimitError, + Storage, +} from "@cap/web-backend"; import { type Organisation, Space, @@ -339,35 +343,50 @@ async function importLoomVideoForOwner({ videoName || `Loom Import - ${new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`; - await db() - .insert(videos) - .values({ - id: videoId, - name, + try { + await createVideoWithShareableLinkQuota({ + client: db(), ownerId, - orgId, - source: { type: "webMP4" as const }, - bucket: Option.getOrNull(writable.bucketId), - storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, - ...(oembedMeta?.duration ? { duration: oembedMeta.duration } : {}), - ...(oembedMeta?.width ? { width: oembedMeta.width } : {}), - ...(oembedMeta?.height ? { height: oembedMeta.height } : {}), + durationSeconds: oembedMeta?.duration, + create: async (tx) => { + await tx.insert(videos).values({ + id: videoId, + name, + ownerId, + orgId, + source: { type: "webMP4" as const }, + bucket: Option.getOrNull(writable.bucketId), + storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + ...(oembedMeta?.duration ? { duration: oembedMeta.duration } : {}), + ...(oembedMeta?.width ? { width: oembedMeta.width } : {}), + ...(oembedMeta?.height ? { height: oembedMeta.height } : {}), + }); + + await tx.insert(videoUploads).values({ + videoId, + phase: "uploading", + processingProgress: 0, + processingMessage: "Importing from Loom...", + }); + + await tx.insert(importedVideos).values({ + id: videoId, + orgId, + source: "loom", + sourceId: loomVideoId, + }); + }, }); - - await db().insert(videoUploads).values({ - videoId, - phase: "uploading", - processingProgress: 0, - processingMessage: "Importing from Loom...", - }); - - await db().insert(importedVideos).values({ - id: videoId, - orgId, - source: "loom", - sourceId: loomVideoId, - }); + } catch (error) { + if (isShareableLinkUsageLimitError(error)) { + return { + success: false, + error: "Shareable link limit reached. Please upgrade to Pro.", + }; + } + throw error; + } const rawFileKey = `${ownerId}/${videoId}/raw-upload.mp4`; @@ -393,6 +412,7 @@ async function importLoomVideoForOwner({ ]); revalidatePath("/dashboard/caps"); + revalidatePath("/dashboard"); return { success: true, videoId }; } From e5b2ab99b07a0ed73c026935976db68349056f93 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 16/36] feat(web): block over-limit durations in processing workflows --- .../.well-known/workflow/v1/manifest.json | 92 +++++++++---------- apps/web/workflows/import-loom-video.ts | 48 +++++++++- apps/web/workflows/process-video.ts | 42 ++++++++- 3 files changed, 131 insertions(+), 51 deletions(-) diff --git a/apps/web/public/.well-known/workflow/v1/manifest.json b/apps/web/public/.well-known/workflow/v1/manifest.json index f5da0be7f7a..c774af18f85 100644 --- a/apps/web/public/.well-known/workflow/v1/manifest.json +++ b/apps/web/public/.well-known/workflow/v1/manifest.json @@ -1,15 +1,18 @@ { "version": "1.0.0", "steps": { - "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/internal/builtins.js": { - "__builtin_response_array_buffer": { - "stepId": "__builtin_response_array_buffer" + "workflows/import-loom-video.ts": { + "downloadLoomToS3": { + "stepId": "step//./workflows/import-loom-video//downloadLoomToS3" }, - "__builtin_response_json": { - "stepId": "__builtin_response_json" + "processVideoOnMediaServer": { + "stepId": "step//./workflows/import-loom-video//processVideoOnMediaServer" }, - "__builtin_response_text": { - "stepId": "__builtin_response_text" + "saveMetadataAndComplete": { + "stepId": "step//./workflows/import-loom-video//saveMetadataAndComplete" + }, + "setProcessingError": { + "stepId": "step//./workflows/import-loom-video//setProcessingError" } }, "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/stdlib.js": { @@ -17,21 +20,15 @@ "stepId": "step//workflow@4.2.0-beta.73//fetch" } }, - "workflows/process-video.ts": { - "cleanupRawUpload": { - "stepId": "step//./workflows/process-video//cleanupRawUpload" - }, - "processVideoOnMediaServer": { - "stepId": "step//./workflows/process-video//processVideoOnMediaServer" - }, - "saveMetadataAndComplete": { - "stepId": "step//./workflows/process-video//saveMetadataAndComplete" + "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/internal/builtins.js": { + "__builtin_response_array_buffer": { + "stepId": "__builtin_response_array_buffer" }, - "setProcessingError": { - "stepId": "step//./workflows/process-video//setProcessingError" + "__builtin_response_json": { + "stepId": "__builtin_response_json" }, - "validateProcessingRequest": { - "stepId": "step//./workflows/process-video//validateProcessingRequest" + "__builtin_response_text": { + "stepId": "__builtin_response_text" } }, "workflows/transcribe.ts": { @@ -66,20 +63,6 @@ "stepId": "step//./workflows/transcribe//validateVideo" } }, - "workflows/import-loom-video.ts": { - "downloadLoomToS3": { - "stepId": "step//./workflows/import-loom-video//downloadLoomToS3" - }, - "processVideoOnMediaServer": { - "stepId": "step//./workflows/import-loom-video//processVideoOnMediaServer" - }, - "saveMetadataAndComplete": { - "stepId": "step//./workflows/import-loom-video//saveMetadataAndComplete" - }, - "setProcessingError": { - "stepId": "step//./workflows/import-loom-video//setProcessingError" - } - }, "workflows/generate-ai.ts": { "fetchTranscript": { "stepId": "step//./workflows/generate-ai//fetchTranscript" @@ -96,19 +79,36 @@ "validateAndSetProcessing": { "stepId": "step//./workflows/generate-ai//validateAndSetProcessing" } + }, + "workflows/process-video.ts": { + "cleanupRawUpload": { + "stepId": "step//./workflows/process-video//cleanupRawUpload" + }, + "processVideoOnMediaServer": { + "stepId": "step//./workflows/process-video//processVideoOnMediaServer" + }, + "saveMetadataAndComplete": { + "stepId": "step//./workflows/process-video//saveMetadataAndComplete" + }, + "setProcessingError": { + "stepId": "step//./workflows/process-video//setProcessingError" + }, + "validateProcessingRequest": { + "stepId": "step//./workflows/process-video//validateProcessingRequest" + } } }, "workflows": { - "workflows/process-video.ts": { - "processVideoWorkflow": { - "workflowId": "workflow//./workflows/process-video//processVideoWorkflow", + "workflows/import-loom-video.ts": { + "importLoomVideoWorkflow": { + "workflowId": "workflow//./workflows/import-loom-video//importLoomVideoWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: processVideoWorkflow", + "label": "Start: importLoomVideoWorkflow", "nodeKind": "workflow_start" } }, @@ -165,16 +165,16 @@ } } }, - "workflows/import-loom-video.ts": { - "importLoomVideoWorkflow": { - "workflowId": "workflow//./workflows/import-loom-video//importLoomVideoWorkflow", + "workflows/generate-ai.ts": { + "generateAiWorkflow": { + "workflowId": "workflow//./workflows/generate-ai//generateAiWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: importLoomVideoWorkflow", + "label": "Start: generateAiWorkflow", "nodeKind": "workflow_start" } }, @@ -198,16 +198,16 @@ } } }, - "workflows/generate-ai.ts": { - "generateAiWorkflow": { - "workflowId": "workflow//./workflows/generate-ai//generateAiWorkflow", + "workflows/process-video.ts": { + "processVideoWorkflow": { + "workflowId": "workflow//./workflows/process-video//processVideoWorkflow", "graph": { "nodes": [ { "id": "start", "type": "workflowStart", "data": { - "label": "Start: generateAiWorkflow", + "label": "Start: processVideoWorkflow", "nodeKind": "workflow_start" } }, diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts index 2757258a649..c2b9ce1e967 100644 --- a/apps/web/workflows/import-loom-video.ts +++ b/apps/web/workflows/import-loom-video.ts @@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto"; import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { Storage } from "@cap/web-backend"; +import { + assertShareableLinkDurationAllowed, + isShareableLinkUsageLimitError, + Storage, +} from "@cap/web-backend"; import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect } from "effect"; @@ -564,15 +568,51 @@ async function saveMetadataAndComplete( ): Promise { "use step"; + const duration = getValidDuration(metadata.duration); + + if (duration !== undefined) { + const [video] = await db() + .select({ + ownerId: videos.ownerId, + isScreenshot: videos.isScreenshot, + }) + .from(videos) + .where(eq(videos.id, videoId as Video.VideoId)); + + if (video) { + try { + await assertShareableLinkDurationAllowed({ + client: db(), + ownerId: video.ownerId, + isScreenshot: video.isScreenshot, + durationSeconds: duration, + }); + } catch (error) { + if (isShareableLinkUsageLimitError(error)) { + await db() + .update(videoUploads) + .set({ + phase: "error", + processingError: "Video exceeds free plan duration limit", + processingMessage: "Upgrade required", + updatedAt: new Date(), + }) + .where(eq(videoUploads.videoId, videoId as Video.VideoId)); + throw new Error("upgrade_required"); + } + + throw error; + } + } + } + await db() .update(videos) .set({ width: metadata.width, height: metadata.height, fps: metadata.fps, - ...(getValidDuration(metadata.duration) === undefined - ? {} - : { duration: metadata.duration }), + ...(duration === undefined ? {} : { duration }), }) .where(eq(videos.id, videoId as Video.VideoId)); diff --git a/apps/web/workflows/process-video.ts b/apps/web/workflows/process-video.ts index 922ccefe354..76592fcc0ee 100644 --- a/apps/web/workflows/process-video.ts +++ b/apps/web/workflows/process-video.ts @@ -1,7 +1,11 @@ import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { Storage } from "@cap/web-backend"; +import { + assertShareableLinkDurationAllowed, + isShareableLinkUsageLimitError, + Storage, +} from "@cap/web-backend"; import { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { FatalError } from "workflow"; @@ -409,6 +413,42 @@ async function saveMetadataAndComplete( const duration = getValidDuration(metadata.duration); + if (duration !== undefined) { + const [video] = await db() + .select({ + ownerId: videos.ownerId, + isScreenshot: videos.isScreenshot, + }) + .from(videos) + .where(eq(videos.id, videoId as Video.VideoId)); + + if (video) { + try { + await assertShareableLinkDurationAllowed({ + client: db(), + ownerId: video.ownerId, + isScreenshot: video.isScreenshot, + durationSeconds: duration, + }); + } catch (error) { + if (isShareableLinkUsageLimitError(error)) { + await db() + .update(videoUploads) + .set({ + phase: "error", + processingError: "Video exceeds free plan duration limit", + processingMessage: "Upgrade required", + updatedAt: new Date(), + }) + .where(eq(videoUploads.videoId, videoId as Video.VideoId)); + throw new Error("upgrade_required"); + } + + throw error; + } + } + } + await db() .update(videos) .set({ From bcb56561b0c431c18ad7a39497a5e5e4b818885c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 17/36] feat(web): show shareable link usage on share page for owners --- .../s/[videoId]/_components/ShareHeader.tsx | 40 ++++++++++++++++--- apps/web/app/s/[videoId]/page.tsx | 8 ++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 28a89a5380f..74831e2e9b4 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -2,7 +2,10 @@ import { buildEnv, NODE_ENV } from "@cap/env"; import { Button } from "@cap/ui"; -import type { ViewerSettingKey } from "@cap/web-backend"; +import type { + ShareableLinkUsageSnapshot, + ViewerSettingKey, +} from "@cap/web-backend"; import { faChartSimple, faChevronDown, @@ -22,6 +25,7 @@ import { useCurrentUser } from "@/app/Layout/AuthContext"; import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; import { UpgradeModal } from "@/components/UpgradeModal"; +import { formatUtcMonthDayOrdinal } from "@/lib/utils"; import { usePublicEnv } from "@/utils/public-env"; import type { VideoData } from "../types"; @@ -32,6 +36,7 @@ export const ShareHeader = ({ sharedOrganizations = [], sharedSpaces = [], spacesData = null, + shareableLinkUsage = null, }: { data: VideoData; customDomain?: string | null; @@ -55,6 +60,7 @@ export const ShareHeader = ({ hasPassword?: boolean; }[]; spacesData?: Spaces[] | null; + shareableLinkUsage?: ShareableLinkUsageSnapshot | null; }) => { const user = useCurrentUser(); const { push, refresh } = useRouter(); @@ -234,21 +240,45 @@ export const ShareHeader = ({ }; const userIsOwnerAndNotPro = user?.id === data.owner.id && !data.owner.isPro; + const shareableLinkUsagePercent = shareableLinkUsage + ? Math.min(100, (shareableLinkUsage.used / shareableLinkUsage.limit) * 100) + : 0; + const shareableLinkResetDate = shareableLinkUsage + ? formatUtcMonthDayOrdinal(shareableLinkUsage.resetAt) + : null; return ( <> {userIsOwnerAndNotPro && (
-

- Shareable links are limited to 5 mins on the free plan. -

+
+

+ {shareableLinkUsage + ? `${shareableLinkUsage.used}/${shareableLinkUsage.limit} shareable links used this month` + : "Shareable links are limited on the free plan"} +

+

+ 5 min max + {shareableLinkUsage && shareableLinkResetDate + ? ` - ${shareableLinkUsage.remaining} left - resets ${shareableLinkResetDate}` + : ""} +

+ {shareableLinkUsage && ( +
+
+
+ )} +
)} diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index e150162fba8..112a4f00e93 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -17,6 +17,7 @@ import { Logo } from "@cap/ui"; import { userIsPro } from "@cap/utils"; import { Database, + getShareableLinkUsage, ImageUploads, provideOptionalAuth, resolveEffectiveVideoRules, @@ -650,17 +651,23 @@ async function AuthorizedContent({ }).pipe(EffectRuntime.runPromise); const viewsPromise = getVideoAnalytics(videoId).then((v) => v.count); + const shareableLinkUsagePromise = + user?.id === video.owner.id && !userIsPro(video.owner) + ? getShareableLinkUsage(db(), video.owner.id) + : Promise.resolve(null); const [ membersList, userOrganizations, sharedOrganizations, { customDomain, domainVerified }, + shareableLinkUsage, ] = await Promise.all([ membersListPromise, userOrganizationsPromise, sharedOrganizationsPromise, customDomainPromise, + shareableLinkUsagePromise, ]); const videoWithOrganizationInfo = await Effect.gen(function* () { @@ -709,6 +716,7 @@ async function AuthorizedContent({ sharedSpaces={sharedSpaces} userOrganizations={userOrganizations} spacesData={spacesData} + shareableLinkUsage={shareableLinkUsage} /> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 18/36] style(web): sentence-case upgrade CTA labels --- .../components/CustomDomainDialog/CustomDomainDialog.tsx | 2 +- apps/web/components/pages/_components/UpgradeToPro.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/CustomDomainDialog.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/CustomDomainDialog.tsx index 0bd00bb3377..cc29a590e74 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/CustomDomainDialog.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/CustomDomainDialog.tsx @@ -466,7 +466,7 @@ const CustomDomainDialog = ({ handleClose(); }} > - Upgrade To Cap Pro + Upgrade to Cap Pro ))} diff --git a/apps/web/components/pages/_components/UpgradeToPro.tsx b/apps/web/components/pages/_components/UpgradeToPro.tsx index ee5bf95fced..fa845161195 100644 --- a/apps/web/components/pages/_components/UpgradeToPro.tsx +++ b/apps/web/components/pages/_components/UpgradeToPro.tsx @@ -1,7 +1,7 @@ import { Button } from "@cap/ui"; import { useRive } from "@rive-app/react-canvas"; -const UpgradeToPro = ({ text = "Upgrade To Cap Pro" }: { text?: string }) => { +const UpgradeToPro = ({ text = "Upgrade to Cap Pro" }: { text?: string }) => { const { rive, RiveComponent: ProRive } = useRive({ src: "/rive/pricing.riv", artboard: "pro", From 87b68f614e55fe9a6ee53eb42bb89f857590d869 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 19/36] feat(web): toast shareable link limit in web recorder --- .../components/web-recorder-dialog/useWebRecorder.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts index 2016cc8d010..06a8d034568 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts @@ -85,6 +85,12 @@ type InstantVideoCreation = { upload: UploadTarget; }; +const isShareableLinkUsageLimitError = (error: unknown) => + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "ShareableLinkUsageLimitError"; + const unwrapExitOrThrow = (exit: Exit.Exit) => { if (Exit.isFailure(exit)) { throw Cause.squash(exit.cause); @@ -1081,7 +1087,11 @@ export const useWebRecorder = ({ } console.error("Failed to start recording", err); - toast.error("Could not start recording."); + if (isShareableLinkUsageLimitError(err)) { + toast.error("Shareable link limit reached. Please upgrade to Pro."); + } else { + toast.error("Could not start recording."); + } await resetState(); } finally { setIsSettingUp(false); From ce50fd1223a02b65a89be77d26675bfbf5caef0f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 20/36] test(web): mock shareable link quota in loom import test --- apps/web/__tests__/unit/loom-import.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/__tests__/unit/loom-import.test.ts b/apps/web/__tests__/unit/loom-import.test.ts index 5dfd63f7aa8..feb559b36b5 100644 --- a/apps/web/__tests__/unit/loom-import.test.ts +++ b/apps/web/__tests__/unit/loom-import.test.ts @@ -100,6 +100,11 @@ vi.mock("@cap/utils", () => ({ })); vi.mock("@cap/web-backend", () => ({ + createVideoWithShareableLinkQuota: vi.fn( + ({ create }: { create: (tx: typeof mockDb) => Promise }) => + create(mockDb), + ), + isShareableLinkUsageLimitError: vi.fn(() => false), Storage: { getWritableAccessForUser: storageGetWritableAccessForUserMock, }, From 706b08097ed43c7a1f992e923b0706144ea2a598 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 21/36] test(web): add shareable link usage helper tests --- .../unit/shareable-link-usage.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 apps/web/__tests__/unit/shareable-link-usage.test.ts diff --git a/apps/web/__tests__/unit/shareable-link-usage.test.ts b/apps/web/__tests__/unit/shareable-link-usage.test.ts new file mode 100644 index 00000000000..f891e90e3ca --- /dev/null +++ b/apps/web/__tests__/unit/shareable-link-usage.test.ts @@ -0,0 +1,63 @@ +import { + FREE_SHAREABLE_LINK_LIMIT, + FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS, + getShareableLinkPeriod, + getShareableLinkUsageLimitError, + toShareableLinkUsageSnapshot, +} from "@cap/web-backend"; +import { describe, expect, it } from "vitest"; + +describe("shareable link usage", () => { + it("uses UTC calendar month boundaries", () => { + const period = getShareableLinkPeriod(new Date("2026-05-31T23:59:59.000Z")); + + expect(period.periodStart.toISOString()).toBe("2026-05-01T00:00:00.000Z"); + expect(period.periodEnd.toISOString()).toBe("2026-06-01T00:00:00.000Z"); + expect(period.resetAt).toBe("2026-06-01T00:00:00.000Z"); + }); + + it("formats remaining usage", () => { + const usage = toShareableLinkUsageSnapshot(12, "2026-06-01T00:00:00.000Z"); + + expect(usage).toEqual({ + used: 12, + limit: FREE_SHAREABLE_LINK_LIMIT, + remaining: 18, + resetAt: "2026-06-01T00:00:00.000Z", + maxDurationSeconds: FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS, + }); + }); + + it("allows the thirtieth shareable link", () => { + const error = getShareableLinkUsageLimitError({ + used: FREE_SHAREABLE_LINK_LIMIT - 1, + resetAt: "2026-06-01T00:00:00.000Z", + durationSeconds: FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS, + }); + + expect(error).toBeNull(); + }); + + it("blocks after the monthly limit is reached", () => { + const error = getShareableLinkUsageLimitError({ + used: FREE_SHAREABLE_LINK_LIMIT, + resetAt: "2026-06-01T00:00:00.000Z", + durationSeconds: FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS, + }); + + expect(error?._tag).toBe("ShareableLinkUsageLimitError"); + expect(error?.reason).toBe("shareable_link_limit"); + expect(error?.usage.remaining).toBe(0); + }); + + it("blocks videos over the free duration limit", () => { + const error = getShareableLinkUsageLimitError({ + used: 0, + resetAt: "2026-06-01T00:00:00.000Z", + durationSeconds: FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS + 1, + }); + + expect(error?._tag).toBe("ShareableLinkUsageLimitError"); + expect(error?.reason).toBe("duration_limit"); + }); +}); From 2251dbe1279fedde042fc8334615ebeb6fdb22a9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 22/36] feat(desktop): track plan shareable usage and drop upgrade window --- apps/desktop/src-tauri/src/auth.rs | 16 +++++++ apps/desktop/src-tauri/src/lib.rs | 60 ++++++++++++++++++------- apps/desktop/src-tauri/src/recording.rs | 3 ++ apps/desktop/src-tauri/src/windows.rs | 42 ----------------- 4 files changed, 62 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index 2be30d10c1a..a4de397adb1 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -34,6 +34,19 @@ pub struct Plan { pub upgraded: bool, pub manual: bool, pub last_checked: i32, + #[serde(default, rename = "shareableLinkUsage")] + pub shareable_link_usage: Option, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone)] +pub struct ShareableLinkUsage { + pub used: i32, + pub limit: i32, + pub remaining: i32, + #[serde(rename = "resetAt")] + pub reset_at: String, + #[serde(rename = "maxDurationSeconds")] + pub max_duration_seconds: i32, } impl AuthStore { @@ -91,6 +104,8 @@ impl AuthStore { #[derive(Deserialize)] struct Response { upgraded: bool, + #[serde(rename = "shareableLinkUsage")] + shareable_link_usage: Option, } let plan_response: Response = response.json().await.map_err(|e| e.to_string())?; @@ -99,6 +114,7 @@ impl AuthStore { upgraded: plan_response.upgraded, last_checked: chrono::Utc::now().timestamp() as i32, manual: auth.plan.as_ref().is_some_and(|p| p.manual), + shareable_link_usage: plan_response.shareable_link_usage, }); auth.organizations = api::fetch_organizations(app) .await diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4c8a8f278da..601f6ac78fa 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -42,7 +42,7 @@ mod window_position_persistence; mod windows; use audio::AppSounds; -use auth::{AuthStore, Plan}; +use auth::{AuthStore, Plan, ShareableLinkUsage}; use camera::{CameraPreviewManager, CameraPreviewState}; use cap_editor::{EditorInstance, EditorState}; use cap_project::{ @@ -2951,9 +2951,15 @@ async fn upload_exported_video( } .await { - Ok(data) => data, + Ok(data) => { + AuthStore::update_auth_plan(&app).await.ok(); + data + } Err(AuthedApiError::InvalidAuthentication) => return Ok(UploadResult::NotAuthenticated), - Err(AuthedApiError::UpgradeRequired) => return Ok(UploadResult::UpgradeRequired), + Err(AuthedApiError::UpgradeRequired) => { + AuthStore::update_auth_plan(&app).await.ok(); + return Ok(UploadResult::UpgradeRequired); + } Err(err) => return Err(err.to_string()), }; @@ -2999,7 +3005,10 @@ async fn upload_exported_video( NotificationType::ShareableLinkCopied.send(&app); Ok(UploadResult::Success(uploaded_video.link)) } - Err(AuthedApiError::UpgradeRequired) => Ok(UploadResult::UpgradeRequired), + Err(AuthedApiError::UpgradeRequired) => { + AuthStore::update_auth_plan(&app).await.ok(); + Ok(UploadResult::UpgradeRequired) + } Err(e) => { error!("Failed to upload video: {e}"); @@ -3031,7 +3040,7 @@ async fn upload_screenshot( }; if !auth.is_upgraded() { - ShowCapWindow::Upgrade.show(&app).await.ok(); + open_upgrade_page(app.clone())?; return Ok(UploadResult::UpgradeRequired); } @@ -3334,6 +3343,10 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { .get("upgraded") .and_then(|v| v.as_bool()) .unwrap_or(false); + let shareable_link_usage = plan_data + .get("shareableLinkUsage") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()); println!("Pro status: {is_pro}"); let updated_auth = AuthStore { secret: auth.secret, @@ -3342,6 +3355,7 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { upgraded: is_pro, manual: auth.plan.map(|p| p.manual).unwrap_or(false), last_checked: chrono::Utc::now().timestamp() as i32, + shareable_link_usage, }), organizations: auth.organizations, organizations_updated_at: auth.organizations_updated_at, @@ -3362,6 +3376,29 @@ fn open_external_link(app: tauri::AppHandle, url: String) -> Result<(), String> return Ok(()); } + app.shell() + .open(&url, None) + .map_err(|e| format!("Failed to open URL: {e}"))?; + if let Some(main) = CapWindowId::Main.get(&app) { + let _ = main.hide(); + } + Ok(()) +} + +fn open_upgrade_page(app: tauri::AppHandle) -> Result<(), String> { + let server_url = GeneralSettingsStore::get(&app) + .ok() + .flatten() + .map(|settings| settings.server_url) + .unwrap_or_else(|| { + std::option_env!("VITE_SERVER_URL") + .unwrap_or("https://cap.so") + .to_string() + }); + let url = format!( + "{}/pricing?utm_source=desktop&utm_campaign=upgrade", + server_url.trim_end_matches('/') + ); app.shell() .open(&url, None) .map_err(|e| format!("Failed to open URL: {e}"))?; @@ -4146,7 +4183,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { CapWindowId::Camera.label().as_str(), CapWindowId::RecordingsOverlay.label().as_str(), CapWindowId::RecordingControls.label().as_str(), - CapWindowId::Upgrade.label().as_str(), "editor", "screenshot-editor", ]) @@ -4643,7 +4679,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { #[cfg(target_os = "macos")] return; } - CapWindowId::Upgrade | CapWindowId::ModeSelect => { + CapWindowId::ModeSelect => { for (label, window) in app.webview_windows() { if let Ok(id) = CapWindowId::from_str(&label) { match id { @@ -4710,16 +4746,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { WindowEvent::Focused(focused) => { let window_id = CapWindowId::from_str(label); - if matches!(window_id, Ok(CapWindowId::Upgrade)) { - for (label, window) in app.webview_windows() { - if let Ok(id) = CapWindowId::from_str(&label) - && matches!(id, CapWindowId::TargetSelectOverlay { .. }) - { - hide_overlay(&window); - } - } - } - if *focused && let Ok(window_id) = window_id && window_id.activates_dock() diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index b16c008fa5e..97a22b5adf2 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -783,6 +783,7 @@ pub async fn start_recording( return Ok(RecordingAction::InvalidAuthentication); } Err(AuthedApiError::UpgradeRequired) => { + AuthStore::update_auth_plan(&app).await.ok(); return Ok(RecordingAction::UpgradeRequired); } Err(err) => { @@ -791,6 +792,8 @@ pub async fn start_recording( } }; + AuthStore::update_auth_plan(&app).await.ok(); + let link = app.make_app_url(format!("/s/{}", s3_config.id)).await; info!("Pre-created shareable link: {}", link); diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index e35f4f0dd02..e5f9856beb2 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -723,7 +723,6 @@ pub enum CapWindowId { CaptureArea, Camera, RecordingControls, - Upgrade, ModeSelect, Debug, ScreenshotEditor { id: u32 }, @@ -742,7 +741,6 @@ impl FromStr for CapWindowId { // legacy identifier "in-progress-recording" => Self::RecordingControls, "recordings-overlay" => Self::RecordingsOverlay, - "upgrade" => Self::Upgrade, "mode-select" => Self::ModeSelect, "debug" => Self::Debug, "onboarding" => Self::Onboarding, @@ -790,7 +788,6 @@ impl std::fmt::Display for CapWindowId { } Self::RecordingControls => write!(f, "in-progress-recording"), // legacy identifier Self::RecordingsOverlay => write!(f, "recordings-overlay"), - Self::Upgrade => write!(f, "upgrade"), Self::ModeSelect => write!(f, "mode-select"), Self::Editor { id } => write!(f, "editor-{id}"), Self::Debug => write!(f, "debug"), @@ -830,7 +827,6 @@ impl CapWindowId { | Self::Editor { .. } | Self::ScreenshotEditor { .. } | Self::Settings - | Self::Upgrade | Self::ModeSelect | Self::Onboarding ) @@ -884,7 +880,6 @@ impl CapWindowId { Self::ScreenshotEditor { .. } => (800.0, 600.0), Self::Settings => (800.0, 580.0), Self::Camera => (200.0, 200.0), - Self::Upgrade => (950.0, 850.0), Self::ModeSelect => (580.0, 340.0), Self::Onboarding => (860.0, 690.0), _ => return None, @@ -922,7 +917,6 @@ pub enum ShowCapWindow { #[serde(default)] capture_target: Option, }, - Upgrade, ModeSelect, ScreenshotEditor { path: PathBuf, @@ -1747,41 +1741,6 @@ impl ShowCapWindow { window } - Self::Upgrade => { - if let Some(main) = CapWindowId::Main.get(app) { - let _ = main.hide(); - } - - let window = self - .window_builder(app, "/upgrade") - .inner_size(950.0, 850.0) - .min_inner_size(950.0, 850.0) - .resizable(false) - .focused(true) - .always_on_top(true) - .maximized(false) - .shadow(true) - .build()?; - - let (pos_x, pos_y) = cursor_monitor.center_position(950.0, 850.0); - let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)); - - #[cfg(windows)] - { - use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(950.0, 850.0)) { - warn!("Failed to set Upgrade window size on Windows: {}", e); - } - if let Err(e) = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)) { - warn!("Failed to position Upgrade window on Windows: {}", e); - } - } - - window.show().ok(); - window.set_focus().ok(); - - window - } Self::ModeSelect => { if let Some(main) = CapWindowId::Main.get(app) { let _ = main.hide(); @@ -2571,7 +2530,6 @@ impl ShowCapWindow { ShowCapWindow::CaptureArea { .. } => CapWindowId::CaptureArea, ShowCapWindow::Camera { .. } => CapWindowId::Camera, ShowCapWindow::InProgressRecording { .. } => CapWindowId::RecordingControls, - ShowCapWindow::Upgrade => CapWindowId::Upgrade, ShowCapWindow::ModeSelect => CapWindowId::ModeSelect, ShowCapWindow::Onboarding => CapWindowId::Onboarding, ShowCapWindow::ScreenshotEditor { path } => { From 2f319504d536efec679d8024963516668b797127 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:59 +0100 Subject: [PATCH 23/36] feat(desktop): surface share link quota and open pricing externally --- apps/desktop/src/App.tsx | 2 - .../routes/(window-chrome)/new-main/index.tsx | 90 ++- .../integrations/google-drive-config.tsx | 3 +- .../settings/integrations/index.tsx | 3 +- .../src/routes/(window-chrome)/upgrade.tsx | 595 ------------------ apps/desktop/src/routes/editor/ExportPage.tsx | 3 +- .../desktop/src/routes/editor/ShareButton.tsx | 3 +- .../desktop/src/routes/recordings-overlay.tsx | 3 +- .../src/routes/target-select-overlay.tsx | 5 +- apps/desktop/src/utils/plans.ts | 34 - apps/desktop/src/utils/recording.ts | 3 +- apps/desktop/src/utils/tauri.ts | 16 +- apps/desktop/src/utils/upgrade.ts | 18 + 13 files changed, 135 insertions(+), 643 deletions(-) delete mode 100644 apps/desktop/src/routes/(window-chrome)/upgrade.tsx delete mode 100644 apps/desktop/src/utils/plans.ts create mode 100644 apps/desktop/src/utils/upgrade.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7bb17821215..b29d92ee0c1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -63,7 +63,6 @@ const SettingsGoogleDriveConfigPage = lazy( const OnboardingPage = lazy( () => import("./routes/(window-chrome)/onboarding"), ); -const UpgradePage = lazy(() => import("./routes/(window-chrome)/upgrade")); const UpdatePage = lazy(() => import("./routes/(window-chrome)/update")); const CameraPage = lazy(() => import("./routes/camera")); const CaptureAreaPage = lazy(() => import("./routes/capture-area")); @@ -185,7 +184,6 @@ function Inner() { /> - diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 82ce03f14f0..74b5a8c8d9f 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -71,6 +71,7 @@ import { type ScreenCaptureTarget, type UploadProgress, } from "~/utils/tauri"; +import { openUpgradePage } from "~/utils/upgrade"; import IconCapLogoFull from "~icons/cap/logo-full"; import IconCapLogoFullDark from "~icons/cap/logo-full-dark"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; @@ -79,6 +80,7 @@ import IconLucideBug from "~icons/lucide/bug"; import IconLucideCircleHelp from "~icons/lucide/circle-help"; import IconLucideImage from "~icons/lucide/image"; import IconLucideImport from "~icons/lucide/import"; +import IconLucideLink from "~icons/lucide/link"; import IconLucideSearch from "~icons/lucide/search"; import IconLucideSettings from "~icons/lucide/settings"; import IconLucideSquarePlay from "~icons/lucide/square-play"; @@ -133,6 +135,21 @@ type RecordingDeviceSettingsStore = { microphoneDeviceSettings?: Record; }; +type ShareableLinkUsage = { + used: number; + limit: number; + remaining: number; + resetAt: string; + maxDurationSeconds: number; +}; + +type PlanWithShareableLinkUsage = { + upgraded?: boolean; + manual?: boolean; + shareableLinkUsage?: ShareableLinkUsage | null; + shareable_link_usage?: ShareableLinkUsage | null; +}; + const recordingDeviceSettingsStore = recordingSettingsStore as unknown as { get: () => Promise; set: (value?: Partial) => Promise; @@ -1505,6 +1522,66 @@ function MainWindowHelpButton() { ); } +function getPlanShareableLinkUsage(plan: unknown) { + const typed = plan as PlanWithShareableLinkUsage | null | undefined; + return typed?.shareableLinkUsage ?? typed?.shareable_link_usage ?? null; +} + +const ordinalSuffix = (day: number) => { + const remainder = day % 100; + if (remainder >= 11 && remainder <= 13) return "th"; + if (day % 10 === 1) return "st"; + if (day % 10 === 2) return "nd"; + if (day % 10 === 3) return "rd"; + return "th"; +}; + +const formatResetDate = (dateString: string) => { + const date = new Date(dateString); + const month = date.toLocaleDateString("en-US", { + month: "long", + timeZone: "UTC", + }); + const day = date.getUTCDate(); + return `${month} ${day}${ordinalSuffix(day)}`; +}; + +function ShareableLinkUsagePill(props: { usage: ShareableLinkUsage }) { + const percent = () => + `${Math.min(100, (props.usage.used / props.usage.limit) * 100)}%`; + const resetDate = () => formatResetDate(props.usage.resetAt); + + return ( + + {props.usage.used}/{props.usage.limit} share links used.{" "} + {props.usage.remaining} left, resets {resetDate()}. + + } + > + + + ); +} + function Page() { const { rawOptions, setOptions } = useRecordingOptions(); const currentRecording = createCurrentRecordingQuery(); @@ -1512,6 +1589,14 @@ function Page() { const isActivelyRecording = () => currentRecording.data?.status === "recording"; const auth = authStore.createQuery(); + const shareableLinkUsage = createMemo(() => { + const plan = auth.data?.plan as + | PlanWithShareableLinkUsage + | null + | undefined; + if (!plan || plan.upgraded || plan.manual) return null; + return getPlanShareableLinkUsage(plan); + }); const recordingSettingsQuery = recordingDeviceSettingsStore.createQuery(); const generalSettings = generalSettingsStore.createQuery(); const deviceSettings = createMemo( @@ -2423,6 +2508,9 @@ function Page() { data-tauri-drag-region > + + {(usage) => } +
Settings}> @@ -2510,7 +2598,7 @@ function Page() { { if (license.data?.type !== "pro") { - await commands.showWindow("Upgrade"); + await openUpgradePage(); } }} class={cx( diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx index 6df09668009..86cc7cf4e7a 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/google-drive-config.tsx @@ -3,6 +3,7 @@ import { useMutation } from "@tanstack/solid-query"; import { createResource, createSignal, Show, Suspense } from "solid-js"; import { createSelectedOrganization } from "~/utils/organization-branding"; import { commands } from "~/utils/tauri"; +import { openUpgradePage } from "~/utils/upgrade"; import { apiClient, protectedHeaders } from "~/utils/web-api"; import { IntegrationConfigHeader } from "./config-header"; @@ -188,7 +189,7 @@ export default function GoogleDriveConfigPage() { }); if (response.status === 403) { - await commands.showWindow("Upgrade"); + await openUpgradePage(); return null; } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx b/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx index 3f5863ed7da..d052f979ba8 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/integrations/index.tsx @@ -7,6 +7,7 @@ import "@total-typescript/ts-reset/filter-boolean"; import { authStore } from "~/store"; import { createSelectedOrganization } from "~/utils/organization-branding"; import { commands } from "~/utils/tauri"; +import { openUpgradePage } from "~/utils/upgrade"; import { apiClient, protectedHeaders } from "~/utils/web-api"; const GoogleDriveIcon = (props: { class?: string }) => ( @@ -91,7 +92,7 @@ export default function AppsTab() { try { if (managedByOrganization()) return; if (app.pro && !isPro()) { - await commands.showWindow("Upgrade"); + await openUpgradePage(); return; } navigate(app.url); diff --git a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx deleted file mode 100644 index a48a27d00e3..00000000000 --- a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx +++ /dev/null @@ -1,595 +0,0 @@ -import { Button } from "@cap/ui-solid"; -import { createMutation, useQueryClient } from "@tanstack/solid-query"; -import { getCurrentWindow, Window } from "@tauri-apps/api/window"; -import { type Accessor, createSignal, Show } from "solid-js"; -import { generalSettingsStore } from "~/store"; -import { getProPlanId } from "~/utils/plans"; -import { createLicenseQuery } from "~/utils/queries"; -import { createRive } from "~/utils/rive"; -import { commands } from "~/utils/tauri"; -import { apiClient, licenseApiClient, protectedHeaders } from "~/utils/web-api"; -import PricingRive from "../../assets/rive/pricing.riv"; -import { authStore } from "../../store"; - -import { Dialog, DialogContent, Input } from "../editor/ui"; - -const proFeatures = [ - "Commercial License Included", - "Unlimited cloud storage & Shareable links", - "Connect custom S3 storage bucket", - "Advanced teams features", - "Unlimited views", - "Password protected videos", - "Advanced analytics", - "Priority support", -]; - -import { RuntimeLoader } from "@rive-app/canvas"; -import riveWASMResource from "@rive-app/canvas/rive.wasm?url"; -import { createSignInMutation } from "~/utils/auth"; - -RuntimeLoader.setWasmUrl(riveWASMResource); - -export default function Page() { - const [isProAnnual, setIsProAnnual] = createSignal(true); - const [isCommercialAnnual, setIsCommercialAnnual] = createSignal(true); - const [upgradeComplete, _setUpgradeComplete] = createSignal(false); - const [loading, setLoading] = createSignal(false); - const signIn = createSignInMutation(); - const license = createLicenseQuery(); - const [openLicenseDialog, setOpenLicenseDialog] = createSignal(false); - - const resetLicense = createMutation(() => ({ - mutationFn: async () => { - const generalSettings = await generalSettingsStore.get(); - if ( - !generalSettings?.instanceId || - !license.data || - license.data.type !== "commercial" - ) { - throw new Error("No instance ID or valid commercial license found"); - } - - const resp = await licenseApiClient.activateCommercialLicense({ - headers: { - licensekey: license.data.licenseKey, - instanceid: generalSettings.instanceId, - }, - body: { reset: true }, - }); - - if (resp.status !== 200) { - if ( - typeof resp.body === "object" && - resp.body && - "message" in resp.body - ) - throw resp.body.message; - throw new Error(String(resp.body)); - } - }, - onSuccess: async () => { - await generalSettingsStore.set({ - commercialLicense: undefined, - }); - license.refetch(); - }, - })); - - const openCheckoutInExternalBrowser = async () => { - console.log("Opening checkout in external browser"); - setLoading(true); - - try { - const auth = await authStore.get(); - - console.log({ auth }); - if (!auth) { - console.log("No auth found, starting sign in flow"); - await signIn.mutateAsync(new AbortController()); - } - - const planId = getProPlanId(isProAnnual() ? "yearly" : "monthly"); - console.log("Getting checkout URL for plan:", planId); - const response = await apiClient.desktop.getProSubscribeURL({ - body: { priceId: planId }, - headers: await protectedHeaders(), - }); - - if (response.status === 200) { - console.log("Opening checkout URL in external browser"); - commands.openExternalLink(response.body.url); - console.log("Minimizing upgrade window"); - const window = await Window.getByLabel("upgrade"); - if (window) await window.minimize(); - } else { - console.error("Failed to get checkout URL, status:", response.status); - } - } catch (error) { - console.error("Error getting checkout URL:", error); - } finally { - setLoading(false); - } - }; - - const openCommercialCheckout = createMutation(() => ({ - mutationFn: async () => { - const resp = await licenseApiClient.createCommercialCheckoutUrl({ - body: { type: isCommercialAnnual() ? "yearly" : "lifetime" }, - }); - - if (resp.status === 200) { - console.log("Opening checkout URL in external browser"); - commands.openExternalLink(resp.body.url); - console.log("Minimizing upgrade window"); - const window = await Window.getByLabel("upgrade"); - if (window) { - await window.minimize(); - } - } else { - throw resp.body; - } - }, - })); - - // onMount(async () => { - // console.log("Component mounted"); - // const unsubscribeDeepLink = await onOpenUrl(async (urls) => { - // console.log("Deep link received:", urls); - // const isDevMode = import.meta.env.VITE_ENVIRONMENT === "development"; - // if (isDevMode) { - // console.log("In dev mode, ignoring deep link"); - // return; - // } - - // for (const url of urls) { - // if (!url.includes("token=")) { - // console.log("URL does not contain token, skipping"); - // return; - // } - - // console.log("Processing auth URL"); - // const urlObject = new URL(url); - // const token = urlObject.searchParams.get("token"); - // const user_id = urlObject.searchParams.get("user_id"); - // const expires = Number(urlObject.searchParams.get("expires")); - - // if (!token || !expires || !user_id) { - // console.error("Invalid signin params"); - // throw new Error("Invalid signin params"); - // } - - // console.log("Setting auth store with new credentials"); - // const existingAuth = await authStore.get(); - // await authStore.set({ - // token, - // user_id, - // expires, - // plan: { - // upgraded: false, - // last_checked: 0, - // manual: existingAuth?.plan?.manual ?? false, - // }, - // }); - - // console.log("Identifying user in analytics"); - // identifyUser(user_id); - // console.log("Tracking sign in event"); - // trackEvent("user_signed_in", { platform: "desktop" }); - - // console.log("Reopening upgrade window"); - // await commands.showWindow("Upgrade"); - - // console.log("Waiting for window to be ready"); - // await new Promise((resolve) => setTimeout(resolve, 500)); - - // console.log("Getting upgrade window reference"); - // const upgradeWindow = await Window.getByLabel("upgrade"); - // if (upgradeWindow) { - // try { - // console.log("Setting focus on upgrade window"); - // await upgradeWindow.show(); - // await upgradeWindow.setFocus(); - // } catch (e) { - // console.error("Failed to focus upgrade window:", e); - // } - // } - - // console.log("Getting checkout URL"); - // const planId = getProPlanId(isProAnnual() ? "yearly" : "monthly"); - // const response = await apiClient.desktop.getProSubscribeURL({ - // body: { priceId: planId }, - // headers: await protectedHeaders(), - // }); - - // if (response.status === 200) { - // console.log("Opening checkout URL in external browser"); - // commands.openExternalLink(response.body.url); - // console.log("Minimizing upgrade window"); - // if (upgradeWindow) { - // await upgradeWindow.minimize(); - // } - // } - // } - // }); - - // onCleanup(() => { - // console.log("Cleaning up deep link listener"); - // unsubscribeDeepLink(); - // }); - - // console.log("Setting up upgrade status check interval"); - // const interval = setInterval(async () => { - // console.log("Checking upgrade status"); - // const result = await commands.checkUpgradedAndUpdate(); - // if (result) { - // console.log("Upgrade complete"); - // setUpgradeComplete(true); - // } - // }, 5000); - // onCleanup(() => { - // console.log("Cleaning up upgrade status check interval"); - // clearInterval(interval); - // }); - // }); - - const { rive: CommercialRive, RiveComponent: Commercial } = createRive( - () => ({ - src: PricingRive, - autoplay: true, - artboard: "commercial", - animations: ["card-stack"], - }), - ); - - const { rive: ProRive, RiveComponent: Pro } = createRive(() => ({ - src: PricingRive, - autoplay: true, - artboard: "pro", - animations: ["items-coming-in"], - })); - - return ( -
- {upgradeComplete() && ( -
-
-

Upgrade complete

-

- You can now close this window - thank you for upgrading! -

- -
-
- )} - {!upgradeComplete() && - (license.data?.type === "commercial" ? ( -
-
-
-

Commercial License

-

- Your license details for Cap commercial use -

-
- -
-
- -

- {license.data.licenseKey} -

-
- - - {(expiryDate) => ( -
- -

- {new Date(expiryDate()).toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - })} -

-
- )} -
- -
- -
-
-
-
- ) : ( - <> -
-

- Early Adopter Pricing -

-
-
-
{ - const riveInstance = CommercialRive(); - if (riveInstance) { - // Stop any current animations first - riveInstance.stop(); - // Play the enter animation - riveInstance.play("cards"); - } - }} - onMouseLeave={() => { - const riveInstance = CommercialRive(); - if (riveInstance) { - // Stop any current animations first - riveInstance.stop(); - // Play the leave animation - riveInstance.play("card-stack"); - } - }} - class="flex flex-col flex-1 justify-between p-3 h-[700px ] bg-gray-3 rounded-2xl border border-gray-3 shadow-sm text-card-foreground md:p-3" - > -
-
- -
-

- Commercial License -

-

- For commercial use -

-
-
-

- {isCommercialAnnual() ? "$29" : "$58"} - .00 / -

- {isCommercialAnnual() && ( -

- billed annually -

- )} - {!isCommercialAnnual() && ( -

- one-time payment -

- )} -
-
setIsCommercialAnnual((v) => !v)} - class="px-3 py-2 text-center rounded-full border border-transparent transition-all duration-200 cursor-pointer bg-gray-5 hover:border-gray-400" - > -

- Switch to {isCommercialAnnual() ? "lifetime" : "yearly"} - :{" "} - - {isCommercialAnnual() ? "$58" : "$29"} - -

-
-
    - {[ - "Commercial Use of Cap Recorder + Editor", - "Community Support", - "Local-only features", - "Perpetual license option", - ].map((feature) => ( -
  • -
    - -
    - - {feature} - -
  • - ))} -
-
-
- -
- -

setOpenLicenseDialog(true)} - class="mb-2 text-sm transition-colors cursor-pointer text-gray-11 hover:text-gray-12" - > - Already have a license key? -

-
-
- - {/* Cap Pro */} -
{ - const riveInstance = ProRive(); - if (riveInstance) { - // Stop any current animations first - riveInstance.stop(); - // Play the enter animation - riveInstance.play("items-coming-out"); - } - }} - onMouseLeave={() => { - const riveInstance = ProRive(); - if (riveInstance) { - // Stop any current animations first - riveInstance.stop(); - // Play the leave animation - riveInstance.play("items-coming-in"); - } - }} - class="flex-grow p-3 h-[700px] flex-1 bg-gray-12 rounded-2xl border shadow-sm text-card-foreground md:p-3" - > -
-
- -
-

- Cap Pro -

-

- For professional use and teams. -

-
-
-

- {isProAnnual() ? "$8.16" : "$12"} - .00 / -

- {isProAnnual() && ( -

- per user, billed annually -

- )} - {!isProAnnual() && ( -

- per user, billed monthly -

- )} -
-
setIsProAnnual((v) => !v)} - class="px-3 py-2 text-center bg-blue-500 rounded-full border border-transparent transition-all duration-200 cursor-pointer hover:border-blue-400" - > -

- Switch to {isProAnnual() ? "monthly" : "yearly"}:{" "} - - {isProAnnual() - ? "$12 per user, billed monthly" - : "$8.16 per user, billed annually"} - -

-
-
    - {proFeatures.map((feature) => ( -
  • -
    - -
    - {feature} -
  • - ))} -
-
- -
-
-
- - ))} -
- ); -} - -interface Props { - open: Accessor; - onOpenChange: (open: boolean) => void; -} - -const ActivateLicenseDialog = ({ open, onOpenChange }: Props) => { - const [licenseKey, setLicenseKey] = createSignal(""); - const queryClient = useQueryClient(); - - const activateLicenseKey = createMutation(() => ({ - mutationFn: async (vars: { licenseKey: string }) => { - const generalSettings = await generalSettingsStore.get(); - if (!generalSettings?.instanceId) { - throw new Error("No instance ID found"); - } - const resp = await licenseApiClient.activateCommercialLicense({ - headers: { - licensekey: vars.licenseKey, - instanceid: generalSettings.instanceId, - }, - body: { reset: false }, - }); - - if (resp.status === 200) - return { ...resp.body, licenseKey: vars.licenseKey }; - if (typeof resp.body === "object" && resp.body && "message" in resp.body) - throw resp.body.message; - throw new Error(String(resp.body)); - }, - onSuccess: async (value) => { - await generalSettingsStore.set({ - commercialLicense: { - activatedOn: Date.now(), - expiryDate: value.expiryDate ?? null, - refresh: value.refresh, - licenseKey: value.licenseKey, - }, - }); - await queryClient.refetchQueries({ queryKey: ["bruh"] }); - }, - })); - return ( - - - activateLicenseKey.mutate({ - licenseKey: licenseKey(), - }) - } - > - Activate - - } - > - setLicenseKey(e.currentTarget.value)} - /> - - - ); -}; diff --git a/apps/desktop/src/routes/editor/ExportPage.tsx b/apps/desktop/src/routes/editor/ExportPage.tsx index 06f9c66fa0f..0f45061a592 100644 --- a/apps/desktop/src/routes/editor/ExportPage.tsx +++ b/apps/desktop/src/routes/editor/ExportPage.tsx @@ -37,6 +37,7 @@ import { type FramesRendered, type UploadProgress, } from "~/utils/tauri"; +import { openUpgradePage } from "~/utils/upgrade"; import { type RenderState, useEditorContext } from "./context"; import { RESOLUTION_OPTIONS } from "./Header"; import { Dialog, Field } from "./ui"; @@ -641,7 +642,7 @@ export function ExportPage() { if (!canShare.allowed) { if (canShare.reason === "upgrade_required") { - await commands.showWindow("Upgrade"); + await openUpgradePage(); await new Promise((resolve) => setTimeout(resolve, 1000)); throw new SilentError(); } diff --git a/apps/desktop/src/routes/editor/ShareButton.tsx b/apps/desktop/src/routes/editor/ShareButton.tsx index a8e7c9818ee..c86d4b0aad2 100644 --- a/apps/desktop/src/routes/editor/ShareButton.tsx +++ b/apps/desktop/src/routes/editor/ShareButton.tsx @@ -9,6 +9,7 @@ import { createProgressBar } from "~/routes/editor/utils"; import { authStore } from "~/store"; import { exportVideo } from "~/utils/export"; import { commands, type UploadProgress } from "~/utils/tauri"; +import { openUpgradePage } from "~/utils/upgrade"; import { useEditorContext } from "./context"; import { RESOLUTION_OPTIONS } from "./Header"; import { @@ -46,7 +47,7 @@ function ShareButton() { if (!canShare.allowed) { if (canShare.reason === "upgrade_required") { - await commands.showWindow("Upgrade"); + await openUpgradePage(); throw new Error( "Upgrade required to share recordings longer than 5 minutes", ); diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index 93fe63d7925..23878f06618 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -33,6 +33,7 @@ import { type UploadProgress, type UploadResult, } from "~/utils/tauri"; +import { openUpgradePage } from "~/utils/upgrade"; import IconCapEditor from "~icons/cap/editor"; import IconCapUpload from "~icons/cap/upload"; import IconLucideClock from "~icons/lucide/clock"; @@ -724,7 +725,7 @@ function createRecordingMutations( if (!canShare.allowed) { if (canShare.reason === "upgrade_required") { - await commands.showWindow("Upgrade"); + await openUpgradePage(); throw new Error( "Upgrade required to share recordings longer than 5 minutes", ); diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index d82c06dee46..5172efe931f 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -59,6 +59,7 @@ import { type ScreenCaptureTarget, type TargetUnderCursor, } from "~/utils/tauri"; +import { openUpgradePage } from "~/utils/upgrade"; import { CameraSelectBase } from "./(window-chrome)/new-main/CameraSelect"; import InfoPill from "./(window-chrome)/new-main/InfoPill"; import { MicrophoneSelectBase } from "./(window-chrome)/new-main/MicrophoneSelect"; @@ -1879,7 +1880,9 @@ function ShowCapFreeWarning(props: { isInstantMode: boolean }) { Instant Mode recordings are limited to 5 mins,{" "} diff --git a/apps/desktop/src/utils/plans.ts b/apps/desktop/src/utils/plans.ts deleted file mode 100644 index e8be405187d..00000000000 --- a/apps/desktop/src/utils/plans.ts +++ /dev/null @@ -1,34 +0,0 @@ -const planIds = { - development: { - yearly: "price_1Q3esrFJxA1XpeSsFwp486RN", - monthly: "price_1P9C1DFJxA1XpeSsTwwuddnq", - }, - production: { - yearly: "price_1S2al7FJxA1XpeSsJCI5Z2UD", - monthly: "price_1S2akxFJxA1XpeSsfoAUUbpJ", - }, -}; - -export const getProPlanId = (billingCycle: "yearly" | "monthly") => { - const environment = - import.meta.env.VITE_ENVIRONMENT === "development" - ? "development" - : "production"; - return planIds[environment]?.[billingCycle] || ""; -}; - -export function isUserOnProPlan({ - subscriptionStatus, -}: { - subscriptionStatus: string | null; -}): boolean { - if ( - subscriptionStatus === "active" || - subscriptionStatus === "trialing" || - subscriptionStatus === "complete" || - subscriptionStatus === "paid" - ) { - return true; - } - return false; -} diff --git a/apps/desktop/src/utils/recording.ts b/apps/desktop/src/utils/recording.ts index 5810d28476f..c317060edbe 100644 --- a/apps/desktop/src/utils/recording.ts +++ b/apps/desktop/src/utils/recording.ts @@ -2,6 +2,7 @@ import { emit } from "@tauri-apps/api/event"; import * as dialog from "@tauri-apps/plugin-dialog"; import type { createOptionsQuery } from "./queries"; import { commands, type RecordingAction } from "./tauri"; +import { openUpgradePage } from "./upgrade"; export function handleRecordingResult( result: Promise, @@ -36,7 +37,7 @@ export function handleRecordingResult( setOptions({ mode: "studio" }); commands.setRecordingMode("studio"); } - } else if (result === "UpgradeRequired") commands.showWindow("Upgrade"); + } else if (result === "UpgradeRequired") await openUpgradePage(); else await dialog.message(`Error: ${result}`, { title: "Error starting recording", diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 22b6d2acf8f..f049b7d59f5 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -11,6 +11,9 @@ async setMicInput(label: string | null) : Promise { async setCameraInput(id: DeviceOrModelID | null, skipCameraWindow: boolean | null) : Promise { return await TAURI_INVOKE("set_camera_input", { id, skipCameraWindow }); }, +async setNativeCameraPreviewEnabled(enabled: boolean) : Promise { + return await TAURI_INVOKE("set_native_camera_preview_enabled", { enabled }); +}, async setRecordingMode(mode: RecordingMode) : Promise { return await TAURI_INVOKE("set_recording_mode", { mode }); }, @@ -104,6 +107,9 @@ async generateExportPreviewFast(frameTime: number, settings: ExportPreviewSettin async startVideoImport(sourcePath: string) : Promise { return await TAURI_INVOKE("start_video_import", { sourcePath }); }, +async startImageImport(sourcePath: string) : Promise { + return await TAURI_INVOKE("start_image_import", { sourcePath }); +}, async checkImportReady(projectPath: string) : Promise { return await TAURI_INVOKE("check_import_ready", { projectPath }); }, @@ -438,7 +444,7 @@ export type AudioConfiguration = { mute: boolean; improve: boolean; micVolumeDb: export type AudioInputLevelChange = number export type AudioMeta = { path: string; start_time?: number | null; device_id?: string | null } export type AuthSecret = { api_key: string } | { token: string; expires: number } -export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; organizations?: Organization[] } +export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; organizations?: Organization[]; organizations_updated_at?: number | null } export type BackgroundBlurConfig = { mode: BackgroundBlurMode } export type BackgroundBlurMode = "off" | "light" | "heavy" export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; roundingType: CornerStyle; inset: number; crop: Crop | null; shadow: number; advancedShadow: ShadowConfiguration | null; border: BorderConfiguration | null } @@ -546,9 +552,10 @@ export type OSPermission = "screenRecording" | "camera" | "microphone" | "access export type OSPermissionStatus = "notNeeded" | "empty" | "granted" | "denied" export type OSPermissionsCheck = { screenRecording: OSPermissionStatus; microphone: OSPermissionStatus; camera: OSPermissionStatus; accessibility: OSPermissionStatus } export type OnEscapePress = null -export type Organization = { id: string; name: string; ownerId: string } +export type Organization = { id: string; name: string; ownerId: string; role?: string; canEditBrand?: boolean; iconUrl?: string | null; brandColors?: OrganizationBrandColors } +export type OrganizationBrandColors = { primary: string | null; secondary: string | null; accent: string | null; background: string | null } export type PhysicalSize = { width: number; height: number } -export type Plan = { upgraded: boolean; manual: boolean; last_checked: number } +export type Plan = { upgraded: boolean; manual: boolean; last_checked: number; shareableLinkUsage?: ShareableLinkUsage | null } export type Platform = "MacOS" | "Windows" export type PostDeletionBehaviour = "doNothing" | "reopenRecordingWindow" export type PostStudioRecordingBehaviour = "openEditor" | "showOverlay" @@ -586,8 +593,9 @@ export type SerializedEditorInstance = { framesSocketUrl: string; recordingDurat export type SerializedScreenshotEditorInstance = { framesSocketUrl: string; path: string; config: ProjectConfiguration | null; prettyName: string; imageWidth: number; imageHeight: number } export type SetCaptureAreaPending = boolean export type ShadowConfiguration = { size: number; opacity: number; blur: number } +export type ShareableLinkUsage = { used: number; limit: number; remaining: number; resetAt: string; maxDurationSeconds: number } export type SharingMeta = { id: string; link: string } -export type ShowCapWindow = { Main: { init_target_mode: RecordingTargetMode | null } } | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId; target_mode: RecordingTargetMode | null } } | { CaptureArea: { screen_id: DisplayId } } | { Camera: { centered: boolean } } | { InProgressRecording: { countdown: number | null; capture_target?: ScreenCaptureTarget | null } } | "Upgrade" | "ModeSelect" | { ScreenshotEditor: { path: string } } | "Onboarding" +export type ShowCapWindow = { Main: { init_target_mode: RecordingTargetMode | null } } | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId; target_mode: RecordingTargetMode | null } } | { CaptureArea: { screen_id: DisplayId } } | { Camera: { centered: boolean } } | { InProgressRecording: { countdown: number | null; capture_target?: ScreenCaptureTarget | null } } | "ModeSelect" | { ScreenshotEditor: { path: string } } | "Onboarding" export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; audio?: AudioMeta | null; cursor?: string | null } export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode; organization_id?: string | null } export type StereoMode = "stereo" | "monoL" | "monoR" diff --git a/apps/desktop/src/utils/upgrade.ts b/apps/desktop/src/utils/upgrade.ts new file mode 100644 index 00000000000..af509277b9f --- /dev/null +++ b/apps/desktop/src/utils/upgrade.ts @@ -0,0 +1,18 @@ +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import * as shell from "@tauri-apps/plugin-shell"; +import { generalSettingsStore } from "~/store"; +import { clientEnv } from "./env"; + +export async function openUpgradePage() { + const settings = await generalSettingsStore.get(); + const url = new URL( + "/pricing", + settings?.serverUrl ?? clientEnv.VITE_SERVER_URL, + ); + url.searchParams.set("utm_source", "desktop"); + url.searchParams.set("utm_campaign", "upgrade"); + await shell.open(url.toString()); + await WebviewWindow.getByLabel("main") + .then((window) => window?.hide()) + .catch(() => {}); +} From 974b1292c57b9bcc1616cbbf6736bd6c73262e03 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:15:06 +0100 Subject: [PATCH 24/36] feat(web-backend): add markShareableLinkUploadRejected helper --- packages/web-backend/src/ShareableLinkUsage.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/web-backend/src/ShareableLinkUsage.ts b/packages/web-backend/src/ShareableLinkUsage.ts index 3e33ce97201..408ee47ff95 100644 --- a/packages/web-backend/src/ShareableLinkUsage.ts +++ b/packages/web-backend/src/ShareableLinkUsage.ts @@ -236,3 +236,18 @@ export async function assertShareableLinkDurationAllowed({ usage, }); } + +export async function markShareableLinkUploadRejected( + client: QueryClient, + videoId: Video.VideoId, +) { + await client + .update(Db.videoUploads) + .set({ + phase: "error", + processingError: "Video exceeds free plan duration limit", + processingMessage: "Upgrade required", + updatedAt: new Date(), + }) + .where(eq(Db.videoUploads.videoId, videoId)); +} From 4f7e2ecd5d0113ad7bc3af865e35a33be193a506 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:15:12 +0100 Subject: [PATCH 25/36] fix(web-backend): detect shareable limits on nested Error causes --- packages/web-backend/src/ShareableLinkUsage.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/web-backend/src/ShareableLinkUsage.ts b/packages/web-backend/src/ShareableLinkUsage.ts index 408ee47ff95..54080ab3a9a 100644 --- a/packages/web-backend/src/ShareableLinkUsage.ts +++ b/packages/web-backend/src/ShareableLinkUsage.ts @@ -74,12 +74,20 @@ export function getShareableLinkUsageLimitError({ export function isShareableLinkUsageLimitError( error: unknown, ): error is Video.ShareableLinkUsageLimitError { - return ( + if ( typeof error === "object" && error !== null && "_tag" in error && error._tag === "ShareableLinkUsageLimitError" - ); + ) { + return true; + } + + if (error instanceof Error) { + return isShareableLinkUsageLimitError((error as { cause?: unknown }).cause); + } + + return false; } export function getShareableLinkLimitResponse( From f3299e6ff839da1b336674d087454514815edce4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:15:18 +0100 Subject: [PATCH 26/36] fix(web-backend): skip error-phase uploads in shareable quotas --- packages/web-backend/src/ShareableLinkUsage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/web-backend/src/ShareableLinkUsage.ts b/packages/web-backend/src/ShareableLinkUsage.ts index 54080ab3a9a..bb243079f3f 100644 --- a/packages/web-backend/src/ShareableLinkUsage.ts +++ b/packages/web-backend/src/ShareableLinkUsage.ts @@ -1,7 +1,7 @@ import * as Db from "@cap/database/schema"; import { userIsPro } from "@cap/utils"; import { type User, Video } from "@cap/web-domain"; -import { and, count, eq, gte, lt } from "drizzle-orm"; +import { and, count, eq, gte, isNull, lt, ne, or } from "drizzle-orm"; import type { DbClient } from "./Database.ts"; export const FREE_SHAREABLE_LINK_LIMIT = 30; @@ -109,10 +109,12 @@ async function countCurrentPeriodShareableLinks( const [row] = await client .select({ value: count() }) .from(Db.videos) + .leftJoin(Db.videoUploads, eq(Db.videoUploads.videoId, Db.videos.id)) .where( and( eq(Db.videos.ownerId, userId), eq(Db.videos.isScreenshot, false), + or(isNull(Db.videoUploads.videoId), ne(Db.videoUploads.phase, "error")), gte(Db.videos.createdAt, periodStart), lt(Db.videos.createdAt, periodEnd), ), From 00780565954fb8905c45d69add94c1e7716582f4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:00 +0100 Subject: [PATCH 27/36] test(web): cover wrapped shareable link limit errors --- apps/web/__tests__/unit/shareable-link-usage.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/__tests__/unit/shareable-link-usage.test.ts b/apps/web/__tests__/unit/shareable-link-usage.test.ts index f891e90e3ca..2127d7677e0 100644 --- a/apps/web/__tests__/unit/shareable-link-usage.test.ts +++ b/apps/web/__tests__/unit/shareable-link-usage.test.ts @@ -3,6 +3,7 @@ import { FREE_SHAREABLE_LINK_MAX_DURATION_SECONDS, getShareableLinkPeriod, getShareableLinkUsageLimitError, + isShareableLinkUsageLimitError, toShareableLinkUsageSnapshot, } from "@cap/web-backend"; import { describe, expect, it } from "vitest"; @@ -60,4 +61,15 @@ describe("shareable link usage", () => { expect(error?._tag).toBe("ShareableLinkUsageLimitError"); expect(error?.reason).toBe("duration_limit"); }); + + it("detects shareable link limit errors wrapped as causes", () => { + const error = getShareableLinkUsageLimitError({ + used: FREE_SHAREABLE_LINK_LIMIT, + resetAt: "2026-06-01T00:00:00.000Z", + }); + + const wrapped = new Error("upgrade_required", { cause: error }); + + expect(isShareableLinkUsageLimitError(wrapped)).toBe(true); + }); }); From 480bedebefc7010e1c4d7bab4f3e17aebce9be8c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:06 +0100 Subject: [PATCH 28/36] fix(desktop): allow only http/https upgrade base URLs --- apps/desktop/src-tauri/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 601f6ac78fa..a089298aeb7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3395,6 +3395,11 @@ fn open_upgrade_page(app: tauri::AppHandle) -> Result<(), String> { .unwrap_or("https://cap.so") .to_string() }); + let server_url = reqwest::Url::parse(&server_url) + .ok() + .filter(|url| matches!(url.scheme(), "http" | "https")) + .map(|url| url.to_string()) + .unwrap_or_else(|| "https://cap.so".to_string()); let url = format!( "{}/pricing?utm_source=desktop&utm_campaign=upgrade", server_url.trim_end_matches('/') From 33627fbeed00b4d6ec3aad6f421367ba91016ed1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:09 +0100 Subject: [PATCH 29/36] fix(settings): expose true video totals in billing usage --- .../app/api/settings/billing/usage/route.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/settings/billing/usage/route.ts b/apps/web/app/api/settings/billing/usage/route.ts index 0e6fd1e1e77..4f16415ad12 100644 --- a/apps/web/app/api/settings/billing/usage/route.ts +++ b/apps/web/app/api/settings/billing/usage/route.ts @@ -1,11 +1,13 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; +import { videos } from "@cap/database/schema"; import { userIsPro } from "@cap/utils"; import { getShareableLinkPeriod, getShareableLinkUsage, toShareableLinkUsageSnapshot, } from "@cap/web-backend"; +import { count, eq } from "drizzle-orm"; export const dynamic = "force-dynamic"; @@ -17,6 +19,18 @@ export async function GET() { } const isPro = userIsPro(user); + const numberOfVideos = await db() + .select({ count: count() }) + .from(videos) + .where(eq(videos.ownerId, user.id)); + + if (!numberOfVideos[0]) { + return Response.json( + { error: "Could not fetch video count" }, + { status: 500 }, + ); + } + const usage = isPro ? toShareableLinkUsageSnapshot(0, getShareableLinkPeriod().resetAt) : await getShareableLinkUsage(db(), user.id); @@ -26,7 +40,7 @@ export async function GET() { { subscription: true, videoLimit: 0, - videoCount: usage.used, + videoCount: numberOfVideos[0].count, shareableLinkUsage: usage, }, { status: 200 }, @@ -36,7 +50,7 @@ export async function GET() { { subscription: false, videoLimit: usage.limit, - videoCount: usage.used, + videoCount: numberOfVideos[0].count, shareableLinkUsage: usage, }, { status: 200 }, From 8709954f28e76e02f46e4ab694ab9355fc2f14e1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:13 +0100 Subject: [PATCH 30/36] fix(webhooks): centralize quota rejection and reply 200 on limits --- .../webhooks/media-server/progress/route.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) 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 7faa911892d..39d2f6387c1 100644 --- a/apps/web/app/api/webhooks/media-server/progress/route.ts +++ b/apps/web/app/api/webhooks/media-server/progress/route.ts @@ -5,6 +5,7 @@ import { assertShareableLinkDurationAllowed, getShareableLinkLimitResponse, isShareableLinkUsageLimitError, + markShareableLinkUploadRejected, Storage, } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; @@ -108,20 +109,13 @@ export async function POST(request: NextRequest) { }); } catch (error) { if (isShareableLinkUsageLimitError(error)) { - await db() - .update(videoUploads) - .set({ - phase: "error", - processingError: "Video exceeds free plan duration limit", - processingMessage: "Upgrade required", - updatedAt: new Date(), - }) - .where( - eq(videoUploads.videoId, payload.videoId as Video.VideoId), - ); + await markShareableLinkUploadRejected( + db(), + payload.videoId as Video.VideoId, + ); return NextResponse.json(getShareableLinkLimitResponse(error), { - status: 403, + status: 200, }); } From 9639e2b9fe225a92bde8cfeea5b0df102842c733 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:20 +0100 Subject: [PATCH 31/36] refactor(web): unify shareable quota rejection in workflows --- apps/web/workflows/import-loom-video.ts | 13 +++---------- apps/web/workflows/process-video.ts | 13 +++---------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts index c2b9ce1e967..b003b0e9da7 100644 --- a/apps/web/workflows/import-loom-video.ts +++ b/apps/web/workflows/import-loom-video.ts @@ -5,6 +5,7 @@ import { serverEnv } from "@cap/env"; import { assertShareableLinkDurationAllowed, isShareableLinkUsageLimitError, + markShareableLinkUploadRejected, Storage, } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -589,16 +590,8 @@ async function saveMetadataAndComplete( }); } catch (error) { if (isShareableLinkUsageLimitError(error)) { - await db() - .update(videoUploads) - .set({ - phase: "error", - processingError: "Video exceeds free plan duration limit", - processingMessage: "Upgrade required", - updatedAt: new Date(), - }) - .where(eq(videoUploads.videoId, videoId as Video.VideoId)); - throw new Error("upgrade_required"); + await markShareableLinkUploadRejected(db(), videoId as Video.VideoId); + throw new Error("upgrade_required", { cause: error }); } throw error; diff --git a/apps/web/workflows/process-video.ts b/apps/web/workflows/process-video.ts index 76592fcc0ee..232ab36f511 100644 --- a/apps/web/workflows/process-video.ts +++ b/apps/web/workflows/process-video.ts @@ -4,6 +4,7 @@ import { serverEnv } from "@cap/env"; import { assertShareableLinkDurationAllowed, isShareableLinkUsageLimitError, + markShareableLinkUploadRejected, Storage, } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -432,16 +433,8 @@ async function saveMetadataAndComplete( }); } catch (error) { if (isShareableLinkUsageLimitError(error)) { - await db() - .update(videoUploads) - .set({ - phase: "error", - processingError: "Video exceeds free plan duration limit", - processingMessage: "Upgrade required", - updatedAt: new Date(), - }) - .where(eq(videoUploads.videoId, videoId as Video.VideoId)); - throw new Error("upgrade_required"); + await markShareableLinkUploadRejected(db(), videoId as Video.VideoId); + throw new Error("upgrade_required", { cause: error }); } throw error; From 81ed85edc3214bf9d3775b1155cfc90cf1b1ce3c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:22 +0100 Subject: [PATCH 32/36] feat(web): persist quota rejects when creating uploads --- apps/web/actions/video/upload.ts | 20 ++++++++++++++------ apps/web/app/api/desktop/[...route]/video.ts | 20 ++++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index f4dfca81266..191da11b204 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -10,6 +10,7 @@ import { assertShareableLinkDurationAllowed, createVideoWithShareableLinkQuota, isShareableLinkUsageLimitError, + markShareableLinkUploadRejected, Storage as StorageService, } from "@cap/web-backend"; import { @@ -155,12 +156,19 @@ export async function createVideoAndGetUploadUrl({ if (existingVideo) { if (existingVideo.ownerId !== user.id) throw new Error("Forbidden"); - await assertShareableLinkDurationAllowed({ - client: db(), - ownerId: user.id, - isScreenshot: existingVideo.isScreenshot, - durationSeconds: duration, - }); + try { + await assertShareableLinkDurationAllowed({ + client: db(), + ownerId: user.id, + isScreenshot: existingVideo.isScreenshot, + durationSeconds: duration, + }); + } catch (error) { + if (isShareableLinkUsageLimitError(error)) { + await markShareableLinkUploadRejected(db(), existingVideo.id); + } + throw error; + } const existingVideoDomain = Video.Video.decodeSync({ ...existingVideo, diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 42414f1fc64..68b4f338796 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -17,6 +17,7 @@ import { createVideoWithShareableLinkQuota, getShareableLinkLimitResponse, isShareableLinkUsageLimitError, + markShareableLinkUploadRejected, Storage, } from "@cap/web-backend"; import { Organisation, Video } from "@cap/web-domain"; @@ -108,12 +109,19 @@ app.get( if (video.ownerId !== user.id) return c.json({ error: "Forbidden" }, { status: 403 }); - await assertShareableLinkDurationAllowed({ - client: db(), - ownerId: user.id, - isScreenshot: video.isScreenshot, - durationSeconds: durationInSecs, - }); + try { + await assertShareableLinkDurationAllowed({ + client: db(), + ownerId: user.id, + isScreenshot: video.isScreenshot, + durationSeconds: durationInSecs, + }); + } catch (error) { + if (isShareableLinkUsageLimitError(error)) { + await markShareableLinkUploadRejected(db(), video.id); + } + throw error; + } return c.json({ id: video.id, From 160da3b285ecf7633d00ed255273db4a3c3c5a78 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:28 +0100 Subject: [PATCH 33/36] fix(upload): honor screenshot uploads in multipart quota check --- apps/web/app/api/upload/[...route]/multipart.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index 830a28f52a6..a28986526b1 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -336,6 +336,7 @@ app.post( assertShareableLinkDurationAllowed({ client: getDb(), ownerId: user.id, + isScreenshot: video.isScreenshot, durationSeconds: body.durationInSecs, }), catch: (cause) => cause, From 46d87c3a342076e4b61940e9391dec00f59343a2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:32 +0100 Subject: [PATCH 34/36] feat(upload): persist quota rejects on multipart completion --- apps/web/app/api/upload/[...route]/multipart.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index a28986526b1..fe1c0000e5c 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -7,6 +7,7 @@ import { getShareableLinkLimitResponse, isShareableLinkUsageLimitError, makeCurrentUserLayer, + markShareableLinkUploadRejected, provideOptionalAuth, Storage, VideosPolicy, @@ -340,7 +341,19 @@ app.post( durationSeconds: body.durationInSecs, }), catch: (cause) => cause, - }); + }).pipe( + Effect.catchAll((error) => + Effect.gen(function* () { + if (isShareableLinkUsageLimitError(error)) { + yield* Effect.promise(() => + markShareableLinkUploadRejected(getDb(), videoId), + ); + } + + return yield* Effect.fail(error); + }), + ), + ); return yield* Effect.gen(function* () { const [bucket] = yield* Storage.getAccessForVideo(video); From a4fa6588d713f608f87e1738c241b6116404da51 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:34 +0100 Subject: [PATCH 35/36] feat(upload): mark quota rejects on recording-complete failures --- apps/web/app/api/upload/[...route]/recording-complete.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/app/api/upload/[...route]/recording-complete.ts b/apps/web/app/api/upload/[...route]/recording-complete.ts index 561eb75e193..c86ee3efeef 100644 --- a/apps/web/app/api/upload/[...route]/recording-complete.ts +++ b/apps/web/app/api/upload/[...route]/recording-complete.ts @@ -5,6 +5,7 @@ import { assertShareableLinkDurationAllowed, getShareableLinkLimitResponse, isShareableLinkUsageLimitError, + markShareableLinkUploadRejected, Storage, } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -312,6 +313,7 @@ export const app = new Hono().post( return c.json({ success: true, jobId: result.jobId }); } catch (error) { if (isShareableLinkUsageLimitError(error)) { + await markShareableLinkUploadRejected(db(), videoId); return c.json(getShareableLinkLimitResponse(error), 403); } From ccf2838566c08da71ea99148831fd08775273422 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 11 May 2026 22:16:34 +0100 Subject: [PATCH 36/36] feat(upload): mark quota rejects on signed upload failures --- apps/web/app/api/upload/[...route]/signed.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/app/api/upload/[...route]/signed.ts b/apps/web/app/api/upload/[...route]/signed.ts index 6585588ec33..e1df8ccc97b 100644 --- a/apps/web/app/api/upload/[...route]/signed.ts +++ b/apps/web/app/api/upload/[...route]/signed.ts @@ -4,6 +4,7 @@ import { assertShareableLinkDurationAllowed, getShareableLinkLimitResponse, isShareableLinkUsageLimitError, + markShareableLinkUploadRejected, Storage, } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -148,8 +149,13 @@ app.post( durationSeconds: durationInSecs, }); } catch (error) { - if (isShareableLinkUsageLimitError(error)) + if (isShareableLinkUsageLimitError(error)) { + await markShareableLinkUploadRejected( + db(), + Video.VideoId.make(videoIdToUse), + ); return c.json(getShareableLinkLimitResponse(error), 403); + } throw error; }